diff --git a/.github/skills/tool-rename-deprecation/SKILL.md b/.github/skills/tool-rename-deprecation/SKILL.md new file mode 100644 index 0000000000000..0d1fa0c663fb2 --- /dev/null +++ b/.github/skills/tool-rename-deprecation/SKILL.md @@ -0,0 +1,149 @@ +--- +name: tool-rename-deprecation +description: 'Ensure renamed built-in tool references preserve backward compatibility. Use when renaming a toolReferenceName, tool set referenceName, or any tool identifier. Run on ANY change to tool registration code. Covers legacyToolReferenceFullNames for tools and legacyFullNames for tool sets.' +--- + +# Tool Rename Deprecation + +When a tool or tool set reference name is changed, the **old name must always be added to the deprecated/legacy array** so that existing prompt files, tool configurations, and saved references continue to resolve correctly. + +## When to Use + +Run this skill on **any change to built-in tool or tool set registration code** to catch regressions: + +- Renaming a tool's `toolReferenceName` +- Renaming a tool set's `referenceName` +- Moving a tool from one tool set to another (the old `toolSet/toolName` path becomes a legacy name) +- Reviewing a PR that modifies tool registration — verify no legacy names were dropped + +## Procedure + +### Step 1 — Identify What Changed + +Determine whether you are renaming a **tool** or a **tool set**, and where it is registered: + +| Entity | Registration | Name field to rename | Legacy array | Stable ID (NEVER change) | +|--------|-------------|---------------------|-------------|-------------------------| +| Tool (`IToolData`) | TypeScript | `toolReferenceName` | `legacyToolReferenceFullNames` | `id` | +| Tool (extension) | `package.json` `languageModelTools` | `toolReferenceName` | `legacyToolReferenceFullNames` | `name` (becomes `id`) | +| Tool set (`IToolSet`) | TypeScript | `referenceName` | `legacyFullNames` | `id` | +| Tool set (extension) | `package.json` `languageModelToolSets` | `name` or `referenceName` | `legacyFullNames` | — | + +**Critical:** For extension-contributed tools, the `name` field in `package.json` is mapped to `id` on `IToolData` (see `languageModelToolsContribution.ts` line `id: rawTool.name`). It is also used for activation events (`onLanguageModelTool:`). **Never rename the `name` field** — only rename `toolReferenceName`. + +### Step 2 — Add the Old Name to the Legacy Array + +**Verify the old `toolReferenceName` value appears in `legacyToolReferenceFullNames`.** Don't assume it's already there — check the actual array contents. If the old name is already listed (e.g., from a previous rename), confirm it wasn't removed. If it's not there, add it. + +**For internal/built-in tools** (TypeScript `IToolData`): + +```typescript +// Before rename +export const MyToolData: IToolData = { + id: 'myExtension.myTool', + toolReferenceName: 'oldName', + // ... +}; + +// After rename — old name preserved +export const MyToolData: IToolData = { + id: 'myExtension.myTool', + toolReferenceName: 'newName', + legacyToolReferenceFullNames: ['oldName'], + // ... +}; +``` + +If the tool previously lived inside a tool set, use the full `toolSet/toolName` form: + +```typescript +legacyToolReferenceFullNames: ['oldToolSet/oldToolName'], +``` + +If renaming multiple times, **accumulate** all prior names — never remove existing entries: + +```typescript +legacyToolReferenceFullNames: ['firstOldName', 'secondOldName'], +``` + +**For tool sets**, add the old name to the `legacyFullNames` option when calling `createToolSet`: + +```typescript +toolsService.createToolSet(source, id, 'newSetName', { + legacyFullNames: ['oldSetName'], +}); +``` + +**For extension-contributed tools** (`package.json`), rename only `toolReferenceName` and add the old value to `legacyToolReferenceFullNames`. **Do NOT rename the `name` field:** + +```jsonc +// CORRECT — only toolReferenceName changes, name stays stable +{ + "name": "copilot_myTool", // ← KEEP this unchanged + "toolReferenceName": "newName", // ← renamed + "legacyToolReferenceFullNames": [ + "oldName" // ← old toolReferenceName preserved + ] +} +``` + +### Step 3 — Check All Consumers of Tool Names + +Legacy names must be respected **everywhere** a tool is looked up by reference name, not just in prompt resolution. Key consumers: + +- **Prompt files** — `getDeprecatedFullReferenceNames()` maps old → current names for `.prompt.md` validation and code actions +- **Tool enablement** — `getToolAliases()` / `getToolSetAliases()` yield legacy names so tool picker and enablement maps resolve them +- **Auto-approval config** — `isToolEligibleForAutoApproval()` checks `legacyToolReferenceFullNames` (including the segment after `/` for namespaced legacy names) against `chat.tools.eligibleForAutoApproval` settings +- **RunInTerminalTool** — has its own local auto-approval check that also iterates `LEGACY_TOOL_REFERENCE_FULL_NAMES` + +After renaming, confirm: +1. `#oldName` in a `.prompt.md` file still resolves (shows no validation error) +2. Tool configurations referencing the old name still activate the tool +3. A user who had `"chat.tools.eligibleForAutoApproval": { "oldName": false }` still has that restriction honored + +### Step 4 — Update References (Optional) + +While legacy names ensure backward compatibility, update first-party references to use the new name: +- System prompts and built-in `.prompt.md` files +- Documentation and model descriptions that mention the tool by reference name +- Test files that reference the old name directly + +## Key Files + +| File | What it contains | +|------|-----------------| +| `src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts` | `IToolData` and `IToolSet` interfaces with legacy name fields | +| `src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts` | Resolution logic: `getToolAliases`, `getToolSetAliases`, `getDeprecatedFullReferenceNames`, `isToolEligibleForAutoApproval` | +| `src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts` | Extension point schema, validation, and the critical `id: rawTool.name` mapping (line ~274) | +| `src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts` | Example of a tool with its own local auto-approval check against legacy names | + +## Real Examples + +- `runInTerminal` tool: renamed from `runCommands/runInTerminal` → `legacyToolReferenceFullNames: ['runCommands/runInTerminal']` +- `todo` tool: renamed from `todos` → `legacyToolReferenceFullNames: ['todos']` +- `getTaskOutput` tool: renamed from `runTasks/getTaskOutput` → `legacyToolReferenceFullNames: ['runTasks/getTaskOutput']` + +## Reference PRs + +- [#277047](https://github.com/microsoft/vscode/pull/277047) — **Design PR**: Introduced `legacyToolReferenceFullNames` and `legacyFullNames`, built the resolution infrastructure, and performed the first batch of tool renames. Use as a template for how to properly rename with legacy names. +- [#278506](https://github.com/microsoft/vscode/pull/278506) — **Consumer-side fix**: After the renames in #277047, the `eligibleForAutoApproval` setting wasn't checking legacy names — users who had restricted the old name lost that restriction. Shows why all consumers of tool reference names must account for legacy names. +- [vscode-copilot-chat#3810](https://github.com/microsoft/vscode-copilot-chat/pull/3810) — **Example of a miss**: Renamed `openSimpleBrowser` → `openIntegratedBrowser` but also changed the `name` field (stable id) from `copilot_openSimpleBrowser` → `copilot_openIntegratedBrowser`. The `toolReferenceName` backward compat only worked by coincidence (the old name happened to already be in the legacy array from a prior change — it was not intentionally added as part of this rename). + +## Regression Check + +Run this check on any PR that touches tool registration (TypeScript `IToolData`, `createToolSet`, or `package.json` `languageModelTools`/`languageModelToolSets`): + +1. **Search the diff for changed `toolReferenceName` or `referenceName` values.** For each change, confirm the **previous value** now appears in `legacyToolReferenceFullNames` or `legacyFullNames`. Don't assume it was already there — read the actual array. +2. **Search the diff for changed `name` fields** on extension-contributed tools. The `name` field is the tool's stable `id` — it must **never** change. If it changed, flag it as a bug. (This breaks activation events, tool invocations by id, and any code referencing the tool by its `name`.) +3. **Verify no entries were removed** from existing legacy arrays. +4. **If a tool moved between tool sets**, confirm the old `toolSet/toolName` full path is in the legacy array. +5. **Check tool set membership lists** (the `tools` array in `languageModelToolSets` contributions). If a tool's `toolReferenceName` changed, any tool set `tools` array referencing the old name should be updated — but the legacy resolution system handles this, so the old name still works. + +## Anti-patterns + +- **Changing the `name` field on extension-contributed tools** — the `name` in `package.json` becomes the `id` on `IToolData` (via `id: rawTool.name` in `languageModelToolsContribution.ts`). Changing it breaks activation events (`onLanguageModelTool:`), any code referencing the tool by id, and tool invocations. Only rename `toolReferenceName`, never `name`. (See [vscode-copilot-chat#3810](https://github.com/microsoft/vscode-copilot-chat/pull/3810) where both `name` and `toolReferenceName` were changed.) +- **Changing the `id` field on TypeScript-registered tools** — same principle as above. The `id` is a stable internal identifier and must never change. +- **Assuming the old name is already in the legacy array** — always verify by reading the actual `legacyToolReferenceFullNames` contents, not just checking that the field exists. A legacy array might list names from an even older rename but not the current one being changed. +- **Removing an old name from the legacy array** — breaks existing saved prompts and user configurations. +- **Forgetting to add the legacy name entirely** — prompt files and tool configs silently stop resolving. +- **Only updating prompt resolution but not other consumers** — auto-approval settings, tool enablement maps, and individual tool checks (like `RunInTerminalTool`) all need to respect legacy names (see [#278506](https://github.com/microsoft/vscode/pull/278506)). diff --git a/build/azure-pipelines/product-build.yml b/build/azure-pipelines/product-build.yml index d8fddac7c3daf..420fcee4958d5 100644 --- a/build/azure-pipelines/product-build.yml +++ b/build/azure-pipelines/product-build.yml @@ -172,7 +172,7 @@ extends: enabled: true configFile: $(Build.SourcesDirectory)/build/azure-pipelines/config/tsaoptions.json binskim: - analyzeTargetGlob: '+:file|$(Agent.BuildDirectory)/VSCode-*/**/*.exe;+:file|$(Agent.BuildDirectory)/VSCode-*/**/*.dll;+:file|$(Agent.BuildDirectory)/VSCode-*/**/*.node;-:file|$(Agent.BuildDirectory)/VSCode-*/**/resources/**/*.node;-:file|$(Build.SourcesDirectory)/.build/**/system-setup/VSCodeSetup*.exe;-:file|$(Build.SourcesDirectory)/.build/**/user-setup/VSCodeUserSetup*.exe' + analyzeTargetGlob: '+:file|$(Agent.BuildDirectory)/VSCode-*/**/*.exe;+:file|$(Agent.BuildDirectory)/VSCode-*/**/*.dll;+:file|$(Agent.BuildDirectory)/VSCode-*/**/*.node;-:file|$(Agent.BuildDirectory)/VSCode-*/**/resources/**/*.exe;-:file|$(Agent.BuildDirectory)/VSCode-*/**/resources/**/*.node;-:file|$(Build.SourcesDirectory)/.build/**/system-setup/VSCodeSetup*.exe;-:file|$(Build.SourcesDirectory)/.build/**/user-setup/VSCodeUserSetup*.exe' codeql: runSourceLanguagesInSourceAnalysis: true compiled: diff --git a/src/vs/base/browser/ui/animations/animations.ts b/src/vs/base/browser/ui/animations/animations.ts new file mode 100644 index 0000000000000..eedf74e514d40 --- /dev/null +++ b/src/vs/base/browser/ui/animations/animations.ts @@ -0,0 +1,475 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ThemeIcon } from '../../../common/themables.js'; +import * as dom from '../../dom.js'; + +export const enum ClickAnimation { + Confetti = 1, + FloatingIcons = 2, + PulseWave = 3, + RadiantLines = 4, +} + +const confettiColors = [ + '#007acc', + '#005a9e', + '#0098ff', + '#4fc3f7', + '#64b5f6', + '#42a5f5', +]; + +let activeOverlay: HTMLElement | undefined; + +/** + * Creates a fixed-positioned overlay centered on the given element. + */ +function createOverlay(element: HTMLElement): { overlay: HTMLElement; cx: number; cy: number } | undefined { + if (activeOverlay) { + return undefined; + } + + const rect = element.getBoundingClientRect(); + const ownerDocument = dom.getWindow(element).document; + + const overlay = dom.$('.animation-overlay'); + overlay.style.position = 'fixed'; + overlay.style.left = `${rect.left}px`; + overlay.style.top = `${rect.top}px`; + overlay.style.width = `${rect.width}px`; + overlay.style.height = `${rect.height}px`; + overlay.style.pointerEvents = 'none'; + overlay.style.overflow = 'visible'; + overlay.style.zIndex = '10000'; + + ownerDocument.body.appendChild(overlay); + activeOverlay = overlay; + + return { overlay, cx: rect.width / 2, cy: rect.height / 2 }; +} + +/** + * Cleans up the overlay after specified period. + */ +function cleanupOverlay(duration: number) { + setTimeout(() => { + if (activeOverlay) { + activeOverlay.remove(); + activeOverlay = undefined; + } + }, duration); +} + +/** + * Bounce the element with a given scale and optional rotation. + */ +export function bounceElement(element: HTMLElement, opts: { scale?: number[]; rotate?: number[]; translateY?: number[]; duration?: number }) { + const frames: Keyframe[] = []; + + const steps = Math.max(opts.scale?.length ?? 0, opts.rotate?.length ?? 0, opts.translateY?.length ?? 0); + if (steps === 0) { + return; + } + + for (let i = 0; i < steps; i++) { + const frame: Keyframe = { offset: steps === 1 ? 1 : i / (steps - 1) }; + let transformParts = ''; + + const scale = opts.scale?.[i]; + if (scale !== undefined) { + transformParts += `scale(${scale})`; + } + + const rotate = opts.rotate?.[i]; + if (rotate !== undefined) { + transformParts += ` rotate(${rotate}deg)`; + } + + const translateY = opts.translateY?.[i]; + if (translateY !== undefined) { + transformParts += ` translateY(${translateY}px)`; + } + + if (transformParts) { + frame.transform = transformParts.trim(); + } + frames.push(frame); + } + + element.animate(frames, { + duration: opts.duration ?? 350, + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + fill: 'forwards', + }); +} + +/** + * Confetti: small particles burst outward in a circle from the element center, + * with an expanding ring. + */ +export function triggerConfettiAnimation(element: HTMLElement) { + const result = createOverlay(element); + if (!result) { + return; + } + + const { overlay, cx, cy } = result; + const rect = element.getBoundingClientRect(); + + // Element bounce + bounceElement(element, { + scale: [1, 1.3, 1], + rotate: [0, -10, 10, 0], + duration: 350, + }); + + // Confetti particles + const particleCount = 10; + for (let i = 0; i < particleCount; i++) { + const size = 3 + (i % 3) * 1.5; + const angle = (i * 36 * Math.PI) / 180; + const distance = 35; + const particleOpacity = 0.6 + (i % 4) * 0.1; + + const part = dom.$('.animation-particle'); + part.style.position = 'absolute'; + part.style.width = `${size}px`; + part.style.height = `${size}px`; + part.style.borderRadius = '50%'; + part.style.backgroundColor = confettiColors[i % confettiColors.length]; + part.style.left = `${cx - size / 2}px`; + part.style.top = `${cy - size / 2}px`; + overlay.appendChild(part); + + const tx = Math.cos(angle) * distance; + const ty = Math.sin(angle) * distance; + + part.animate([ + { opacity: 0, transform: 'scale(0) translate(0, 0)' }, + { opacity: particleOpacity, transform: `scale(1) translate(${tx * 0.5}px, ${ty * 0.5}px)`, offset: 0.3 }, + { opacity: particleOpacity, transform: `scale(1) translate(${tx}px, ${ty}px)`, offset: 0.7 }, + { opacity: 0, transform: `scale(0) translate(${tx}px, ${ty}px)` }, + ], { + duration: 1100, + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + fill: 'forwards', + }); + } + + // Expanding ring + const ring = dom.$('.animation-particle'); + ring.style.position = 'absolute'; + ring.style.left = '0'; + ring.style.top = '0'; + ring.style.width = `${rect.width}px`; + ring.style.height = `${rect.height}px`; + ring.style.borderRadius = '50%'; + ring.style.border = '2px solid var(--vscode-focusBorder, #007acc)'; + ring.style.boxSizing = 'border-box'; + overlay.appendChild(ring); + + ring.animate([ + { transform: 'scale(1)', opacity: 1 }, + { transform: 'scale(2)', opacity: 0 }, + ], { + duration: 800, + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + fill: 'forwards', + }); + + cleanupOverlay(2000); +} + +/** + * Floating Icons: small icons float upward from the element. + */ +export function triggerFloatingIconsAnimation(element: HTMLElement, icon: ThemeIcon) { + const result = createOverlay(element); + if (!result) { + return; + } + + const { overlay, cx, cy } = result; + const rect = element.getBoundingClientRect(); + + // Element bounce upward + bounceElement(element, { + translateY: [0, -6, 0], + duration: 350, + }); + + // Floating icons + const iconCount = 6; + for (let i = 0; i < iconCount; i++) { + const size = 12 + (i % 3) * 2; + const iconEl = dom.$('.animation-particle'); + iconEl.style.position = 'absolute'; + iconEl.style.left = `${cx}px`; + iconEl.style.top = `${cy}px`; + iconEl.style.fontSize = `${size}px`; + iconEl.style.lineHeight = '1'; + iconEl.style.color = 'var(--vscode-focusBorder, #007acc)'; + iconEl.classList.add(...ThemeIcon.asClassNameArray(icon)); + overlay.appendChild(iconEl); + + const driftX = (Math.random() - 0.5) * 50; + const floatY = -50 - (i % 3) * 10; + const rotate1 = (Math.random() - 0.5) * 20; + const rotate2 = (Math.random() - 0.5) * 40; + + iconEl.animate([ + { opacity: 0, transform: `translate(-50%, -50%) scale(0) rotate(${rotate1}deg)` }, + { opacity: 1, transform: `translate(calc(-50% + ${driftX * 0.3}px), calc(-50% + ${floatY * 0.3}px)) scale(1) rotate(${(rotate1 + rotate2) * 0.3}deg)`, offset: 0.3 }, + { opacity: 1, transform: `translate(calc(-50% + ${driftX * 0.7}px), calc(-50% + ${floatY * 0.7}px)) scale(1) rotate(${(rotate1 + rotate2) * 0.7}deg)`, offset: 0.7 }, + { opacity: 0, transform: `translate(calc(-50% + ${driftX}px), calc(-50% + ${floatY}px)) scale(0.8) rotate(${rotate2}deg)` }, + ], { + duration: 800 + (i % 3) * 200, + delay: i * 80, + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + fill: 'forwards', + }); + } + + // Expanding ring + const ring = dom.$('.animation-particle'); + ring.style.position = 'absolute'; + ring.style.left = '0'; + ring.style.top = '0'; + ring.style.width = `${rect.width}px`; + ring.style.height = `${rect.height}px`; + ring.style.borderRadius = '50%'; + ring.style.border = '2px solid var(--vscode-focusBorder, #007acc)'; + ring.style.boxSizing = 'border-box'; + overlay.appendChild(ring); + + ring.animate([ + { transform: 'scale(1)', opacity: 1 }, + { transform: 'scale(2)', opacity: 0 }, + ], { + duration: 500, + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + fill: 'forwards', + }); + + cleanupOverlay(2000); +} + +/** + * Pulse Wave: expanding rings and sparkle dots radiate from the element center. + */ +export function triggerPulseWaveAnimation(element: HTMLElement) { + const result = createOverlay(element); + if (!result) { + return; + } + + const { overlay, cx, cy } = result; + const rect = element.getBoundingClientRect(); + + // Element bounce with slight rotation + bounceElement(element, { + scale: [1, 1.1, 1], + rotate: [0, -12, 0], + duration: 400, + }); + + // Expanding rings + for (let i = 0; i < 2; i++) { + const ring = dom.$('.animation-particle'); + ring.style.position = 'absolute'; + ring.style.left = '0'; + ring.style.top = '0'; + ring.style.width = `${rect.width}px`; + ring.style.height = `${rect.height}px`; + ring.style.borderRadius = '50%'; + ring.style.border = '2px solid var(--vscode-focusBorder, #007acc)'; + ring.style.boxSizing = 'border-box'; + overlay.appendChild(ring); + + ring.animate([ + { transform: 'scale(0.8)', opacity: 0 }, + { transform: 'scale(0.8)', opacity: 0.6, offset: 0.01 }, + { transform: 'scale(2.5)', opacity: 0 }, + ], { + duration: 800, + delay: i * 150, + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + fill: 'forwards', + }); + } + + // Sparkle dots + for (let i = 0; i < 6; i++) { + const angle = (i * 60 * Math.PI) / 180; + const distance = 30 + (i % 2) * 10; + const size = 3.5; + + const dot = dom.$('.animation-particle'); + dot.style.position = 'absolute'; + dot.style.width = `${size}px`; + dot.style.height = `${size}px`; + dot.style.borderRadius = '50%'; + dot.style.backgroundColor = '#0098ff'; + dot.style.left = `${cx - size / 2}px`; + dot.style.top = `${cy - size / 2}px`; + overlay.appendChild(dot); + + const tx = Math.cos(angle) * distance; + const ty = Math.sin(angle) * distance; + + dot.animate([ + { opacity: 0, transform: 'scale(0) translate(0, 0)' }, + { opacity: 1, transform: `scale(1) translate(${tx}px, ${ty}px)`, offset: 0.5 }, + { opacity: 0, transform: `scale(0) translate(${tx}px, ${ty}px)` }, + ], { + duration: 600, + delay: 100 + i * 50, + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + fill: 'forwards', + }); + } + + // Background glow + const glow = dom.$('.animation-particle'); + glow.style.position = 'absolute'; + glow.style.left = '0'; + glow.style.top = '0'; + glow.style.width = `${rect.width}px`; + glow.style.height = `${rect.height}px`; + glow.style.borderRadius = '50%'; + glow.style.backgroundColor = 'var(--vscode-focusBorder, #007acc)'; + overlay.appendChild(glow); + + glow.animate([ + { transform: 'scale(0.9)', opacity: 0 }, + { transform: 'scale(0.9)', opacity: 0.5, offset: 0.01 }, + { transform: 'scale(1.5)', opacity: 0 }, + ], { + duration: 500, + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + fill: 'forwards', + }); + + cleanupOverlay(2000); +} + +/** + * Radiant Lines: lines and dots emanate outward from the element center. + */ +export function triggerRadiantLinesAnimation(element: HTMLElement) { + const result = createOverlay(element); + if (!result) { + return; + } + + const { overlay, cx, cy } = result; + + // Element scale bounce + bounceElement(element, { + scale: [1, 1.15, 1], + duration: 350, + }); + + // Dots at offset angles + for (let i = 0; i < 8; i++) { + const size = 3; + const dotOpacity = 0.7; + const angle = ((i * 45 + 22.5) * Math.PI) / 180; + const startDistance = 14; + const endDistance = 30; + + const dot = dom.$('.animation-particle'); + dot.style.position = 'absolute'; + dot.style.width = `${size}px`; + dot.style.height = `${size}px`; + dot.style.borderRadius = '50%'; + dot.style.backgroundColor = 'var(--vscode-editor-foreground, #ffffff)'; + dot.style.left = `${cx - size / 2}px`; + dot.style.top = `${cy - size / 2}px`; + overlay.appendChild(dot); + + const startX = Math.cos(angle) * startDistance; + const startY = Math.sin(angle) * startDistance; + const endX = Math.cos(angle) * endDistance; + const endY = Math.sin(angle) * endDistance; + + dot.animate([ + { opacity: 0, transform: `scale(0) translate(${startX}px, ${startY}px)` }, + { opacity: dotOpacity, transform: `scale(1.2) translate(${(startX + endX) / 2}px, ${(startY + endY) / 2}px)`, offset: 0.25 }, + { opacity: dotOpacity, transform: `scale(1) translate(${endX * 0.8}px, ${endY * 0.8}px)`, offset: 0.5 }, + { opacity: dotOpacity * 0.5, transform: `scale(1) translate(${endX}px, ${endY}px)`, offset: 0.75 }, + { opacity: 0, transform: `scale(0.5) translate(${endX}px, ${endY}px)` }, + ], { + duration: 1100, + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + fill: 'forwards', + }); + } + + // Radiant lines + for (let i = 0; i < 8; i++) { + const angleDeg = i * 45; + + const lineWrapper = dom.$('.animation-particle'); + lineWrapper.style.position = 'absolute'; + lineWrapper.style.left = `${cx}px`; + lineWrapper.style.top = `${cy}px`; + lineWrapper.style.width = '0'; + lineWrapper.style.height = '0'; + lineWrapper.style.transform = `rotate(${angleDeg}deg)`; + overlay.appendChild(lineWrapper); + + const line = dom.$('.animation-particle'); + line.style.position = 'absolute'; + line.style.width = '2px'; + line.style.height = '10px'; + line.style.backgroundColor = 'var(--vscode-focusBorder, #007acc)'; + line.style.left = '-1px'; + line.style.top = '-22px'; + line.style.transformOrigin = 'bottom center'; + lineWrapper.appendChild(line); + + line.animate([ + { transform: 'scale(1, 0)', opacity: 0.6 }, + { transform: 'scale(1, 1)', opacity: 0.6, offset: 0.2 }, + { transform: 'scale(1, 1)', opacity: 0.6, offset: 0.6 }, + { transform: 'scale(1, 1)', opacity: 0.6, offset: 0.8 }, + { transform: 'scale(0, 0.3)', opacity: 0 }, + ], { + duration: 1200, + delay: 150, + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + fill: 'forwards', + }); + } + + cleanupOverlay(2000); +} + +/** + * Triggers the specified click animation on the element. + * @param element The target element to animate. + * @param animation The type of click animation to trigger. + * @param icon Optional icon for animations that require it (e.g., FloatingIcons). + */ +export function triggerClickAnimation(element: HTMLElement, animation: ClickAnimation, icon?: ThemeIcon) { + switch (animation) { + case ClickAnimation.Confetti: + triggerConfettiAnimation(element); + break; + case ClickAnimation.FloatingIcons: + if (icon) { + triggerFloatingIconsAnimation(element, icon); + } + break; + case ClickAnimation.PulseWave: + triggerPulseWaveAnimation(element); + break; + case ClickAnimation.RadiantLines: + triggerRadiantLinesAnimation(element); + break; + } +} diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index b3d1ed909f94c..c362972f1d770 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -488,33 +488,6 @@ export class ActionList extends Disposable { this._filterText = this._filterInput!.value; this._applyFilter(); })); - - // Keyboard navigation from filter input - this._register(dom.addDisposableListener(this._filterInput, 'keydown', (e: KeyboardEvent) => { - if (e.key === 'ArrowUp') { - e.preventDefault(); - this._list.domFocus(); - const lastIndex = this._list.length - 1; - if (lastIndex >= 0) { - this._list.focusLast(undefined, this.focusCondition); - } - } else if (e.key === 'ArrowDown') { - e.preventDefault(); - this._list.domFocus(); - this.focusNext(); - } else if (e.key === 'Enter') { - e.preventDefault(); - this.acceptSelected(); - } else if (e.key === 'Escape') { - if (this._filterText) { - e.preventDefault(); - e.stopPropagation(); - this._filterInput!.value = ''; - this._filterText = ''; - this._applyFilter(); - } - } - })); } this._applyFilter(); @@ -546,10 +519,10 @@ export class ActionList extends Disposable { } else { this._collapsedSections.add(section); } - this._applyFilter(true); + this._applyFilter(); } - private _applyFilter(reposition?: boolean): void { + private _applyFilter(): void { const filterLower = this._filterText.toLowerCase(); const isFiltering = filterLower.length > 0; const visible: IActionListItem[] = []; @@ -647,9 +620,7 @@ export class ActionList extends Disposable { } } // Reposition the context view so the widget grows in the correct direction - if (reposition) { - this._contextViewService.layout(); - } + this._contextViewService.layout(); } } @@ -708,6 +679,16 @@ export class ActionList extends Disposable { this._contextViewService.hideContextView(); } + clearFilter(): boolean { + if (this._filterInput && this._filterText) { + this._filterInput.value = ''; + this._filterText = ''; + this._applyFilter(); + return true; + } + return false; + } + private hasDynamicHeight(): boolean { if (this._options?.showFilter) { return true; @@ -867,17 +848,41 @@ export class ActionList extends Disposable { } focusPrevious() { + if (this._filterInput && dom.isActiveElement(this._filterInput)) { + this._list.domFocus(); + this._list.focusLast(undefined, this.focusCondition); + return; + } + const previousFocus = this._list.getFocus(); this._list.focusPrevious(1, true, undefined, this.focusCondition); const focused = this._list.getFocus(); if (focused.length > 0) { + // If focus wrapped (was at first focusable, now at last), move to filter instead + if (this._filterInput && previousFocus.length > 0 && focused[0] > previousFocus[0]) { + this._list.setFocus([]); + this._filterInput.focus(); + return; + } this._list.reveal(focused[0]); } } focusNext() { + if (this._filterInput && dom.isActiveElement(this._filterInput)) { + this._list.domFocus(); + this._list.focusFirst(undefined, this.focusCondition); + return; + } + const previousFocus = this._list.getFocus(); this._list.focusNext(1, true, undefined, this.focusCondition); const focused = this._list.getFocus(); if (focused.length > 0) { + // If focus wrapped (was at last focusable, now at first), move to filter instead + if (this._filterInput && previousFocus.length > 0 && focused[0] < previousFocus[0]) { + this._list.setFocus([]); + this._filterInput.focus(); + return; + } this._list.reveal(focused[0]); } } diff --git a/src/vs/platform/actionWidget/browser/actionWidget.ts b/src/vs/platform/actionWidget/browser/actionWidget.ts index 3e43f12b1957c..58792a384ff5a 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.ts +++ b/src/vs/platform/actionWidget/browser/actionWidget.ts @@ -103,6 +103,10 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { return this._list?.value?.toggleFocusedSection() ?? false; } + clearFilter(): boolean { + return this._list?.value?.clearFilter() ?? false; + } + hide(didCancel?: boolean) { this._list.value?.hide(didCancel); this._list.clear(); @@ -220,6 +224,29 @@ registerAction2(class extends Action2 { } }); +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'clearFilterCodeActionWidget', + title: localize2('clearFilterCodeActionWidget.title', "Clear action widget filter"), + precondition: ContextKeyExpr.and(ActionWidgetContextKeys.Visible, ActionWidgetContextKeys.FilterFocused), + keybinding: { + weight: weight + 1, + primary: KeyCode.Escape, + } + }); + } + + run(accessor: ServicesAccessor): void { + const widgetService = accessor.get(IActionWidgetService); + if (widgetService instanceof ActionWidgetService) { + if (!widgetService.clearFilter()) { + widgetService.hide(true); + } + } + } +}); + registerAction2(class extends Action2 { constructor() { super({ diff --git a/src/vs/platform/actions/browser/menuEntryActionViewItem.ts b/src/vs/platform/actions/browser/menuEntryActionViewItem.ts index feb53c1efcb5e..8f61cd7050578 100644 --- a/src/vs/platform/actions/browser/menuEntryActionViewItem.ts +++ b/src/vs/platform/actions/browser/menuEntryActionViewItem.ts @@ -31,6 +31,7 @@ import { INotificationService } from '../../notification/common/notification.js' import { IStorageService, StorageScope, StorageTarget } from '../../storage/common/storage.js'; import { defaultSelectBoxStyles } from '../../theme/browser/defaultStyles.js'; import { asCssVariable, selectBorder } from '../../theme/common/colorRegistry.js'; +import { ClickAnimation, triggerClickAnimation } from '../../../base/browser/ui/animations/animations.js'; import { isDark } from '../../theme/common/theme.js'; import { IThemeService } from '../../theme/common/themeService.js'; import { hasNativeContextMenu } from '../../window/common/window.js'; @@ -173,6 +174,7 @@ export interface IMenuEntryActionViewItemOptions { readonly keybinding?: string | null; readonly hoverDelegate?: IHoverDelegate; readonly keybindingNotRenderedWithLabel?: boolean; + readonly onClickAnimation?: ClickAnimation; } export class MenuEntryActionViewItem extends ActionViewItem { @@ -207,6 +209,11 @@ export class MenuEntryActionViewItem('IMcpGatew export const McpGatewayChannelName = 'mcpGateway'; export const McpGatewayToolBrokerChannelName = 'mcpGatewayToolBroker'; +export interface IGatewayCallToolResult { + result: MCP.CallToolResult; + serverIndex: number; +} + +export interface IGatewayServerResources { + serverIndex: number; + resources: readonly MCP.Resource[]; +} + +export interface IGatewayServerResourceTemplates { + serverIndex: number; + resourceTemplates: readonly MCP.ResourceTemplate[]; +} + export interface IMcpGatewayToolInvoker { readonly onDidChangeTools: Event; + readonly onDidChangeResources: Event; listTools(): Promise; - callTool(name: string, args: Record): Promise; + callTool(name: string, args: Record): Promise; + listResources(): Promise; + readResource(serverIndex: number, uri: string): Promise; + listResourceTemplates(): Promise; } /** diff --git a/src/vs/platform/mcp/node/mcpGatewayChannel.ts b/src/vs/platform/mcp/node/mcpGatewayChannel.ts index d8a976f3308a2..0b0ce1edb0ac8 100644 --- a/src/vs/platform/mcp/node/mcpGatewayChannel.ts +++ b/src/vs/platform/mcp/node/mcpGatewayChannel.ts @@ -6,7 +6,7 @@ import { Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { IPCServer, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { IMcpGatewayService, McpGatewayToolBrokerChannelName } from '../common/mcpGateway.js'; +import { IGatewayCallToolResult, IGatewayServerResources, IGatewayServerResourceTemplates, IMcpGatewayService, McpGatewayToolBrokerChannelName } from '../common/mcpGateway.js'; import { MCP } from '../common/modelContextProtocol.js'; /** @@ -35,8 +35,12 @@ export class McpGatewayChannel extends Disposable implements IServerCh const brokerChannel = ipcChannelForContext(this._ipcServer, ctx); const result = await this.mcpGatewayService.createGateway(ctx, { onDidChangeTools: brokerChannel.listen('onDidChangeTools'), + onDidChangeResources: brokerChannel.listen('onDidChangeResources'), listTools: () => brokerChannel.call('listTools'), - callTool: (name, callArgs) => brokerChannel.call('callTool', { name, args: callArgs }), + callTool: (name, callArgs) => brokerChannel.call('callTool', { name, args: callArgs }), + listResources: () => brokerChannel.call('listResources'), + readResource: (serverIndex, uri) => brokerChannel.call('readResource', { serverIndex, uri }), + listResourceTemplates: () => brokerChannel.call('listResourceTemplates'), }); return result as T; } diff --git a/src/vs/platform/mcp/node/mcpGatewaySession.ts b/src/vs/platform/mcp/node/mcpGatewaySession.ts index 691453ec05932..836d6571e3b5b 100644 --- a/src/vs/platform/mcp/node/mcpGatewaySession.ts +++ b/src/vs/platform/mcp/node/mcpGatewaySession.ts @@ -19,6 +19,56 @@ const MCP_INVALID_REQUEST = -32600; const MCP_METHOD_NOT_FOUND = -32601; const MCP_INVALID_PARAMS = -32602; +const GATEWAY_URI_AUTHORITY_RE = /^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^/?#]*)(.*)/; + +/** + * Encodes a resource URI for the gateway by appending `-{serverIndex}` to the authority. + * This namespaces resources from different MCP servers served through the same gateway. + */ +export function encodeGatewayResourceUri(uri: string, serverIndex: number): string { + const match = uri.match(GATEWAY_URI_AUTHORITY_RE); + if (!match) { + return uri; + } + const [, prefix, authority, rest] = match; + return `${prefix}${authority}-${serverIndex}${rest}`; +} + +/** + * Decodes a gateway-encoded resource URI, extracting the server index and original URI. + */ +export function decodeGatewayResourceUri(uri: string): { serverIndex: number; originalUri: string } { + const match = uri.match(GATEWAY_URI_AUTHORITY_RE); + if (!match) { + throw new JsonRpcError(MCP_INVALID_PARAMS, `Invalid resource URI: ${uri}`); + } + const [, prefix, authority, rest] = match; + const suffixMatch = authority.match(/^(.*)-([0-9]+)$/); + if (!suffixMatch) { + throw new JsonRpcError(MCP_INVALID_PARAMS, `Invalid gateway resource URI (no server index): ${uri}`); + } + const [, originalAuthority, indexStr] = suffixMatch; + return { + serverIndex: parseInt(indexStr, 10), + originalUri: `${prefix}${originalAuthority}${rest}`, + }; +} + +function encodeResourceUrisInContent(content: MCP.ContentBlock[], serverIndex: number): MCP.ContentBlock[] { + return content.map(block => { + if (block.type === 'resource_link') { + return { ...block, uri: encodeGatewayResourceUri(block.uri, serverIndex) }; + } + if (block.type === 'resource') { + return { + ...block, + resource: { ...block.resource, uri: encodeGatewayResourceUri(block.resource.uri, serverIndex) }, + }; + } + return block; + }); +} + export class McpGatewaySession extends Disposable { private readonly _rpc: JsonRpcProtocol; private readonly _sseClients = new Set(); @@ -50,6 +100,14 @@ export class McpGatewaySession extends Disposable { this._rpc.sendNotification({ method: 'notifications/tools/list_changed' }); })); + + this._register(this._toolInvoker.onDidChangeResources(() => { + if (!this._isInitialized) { + return; + } + + this._rpc.sendNotification({ method: 'notifications/resources/list_changed' }); + })); } public attachSseClient(_req: http.IncomingMessage, res: http.ServerResponse): void { @@ -148,6 +206,12 @@ export class McpGatewaySession extends Disposable { return this._handleListTools(); case 'tools/call': return this._handleCallTool(request); + case 'resources/list': + return this._handleListResources(); + case 'resources/read': + return this._handleReadResource(request); + case 'resources/templates/list': + return this._handleListResourceTemplates(); default: throw new JsonRpcError(MCP_METHOD_NOT_FOUND, `Method not found: ${request.method}`); } @@ -157,6 +221,7 @@ export class McpGatewaySession extends Disposable { if (notification.method === 'notifications/initialized') { this._isInitialized = true; this._rpc.sendNotification({ method: 'notifications/tools/list_changed' }); + this._rpc.sendNotification({ method: 'notifications/resources/list_changed' }); } } @@ -167,6 +232,9 @@ export class McpGatewaySession extends Disposable { tools: { listChanged: true, }, + resources: { + listChanged: true, + }, }, serverInfo: { name: 'VS Code MCP Gateway', @@ -175,7 +243,7 @@ export class McpGatewaySession extends Disposable { }; } - private _handleCallTool(request: IJsonRpcRequest): unknown { + private async _handleCallTool(request: IJsonRpcRequest): Promise { const params = typeof request.params === 'object' && request.params ? request.params as Record : undefined; if (!params || typeof params.name !== 'string') { throw new JsonRpcError(MCP_INVALID_PARAMS, 'Missing tool call params'); @@ -189,16 +257,71 @@ export class McpGatewaySession extends Disposable { ? params.arguments as Record : {}; - return this._toolInvoker.callTool(params.name, argumentsValue).catch(error => { + try { + const { result, serverIndex } = await this._toolInvoker.callTool(params.name, argumentsValue); + return { + ...result, + content: encodeResourceUrisInContent(result.content, serverIndex), + }; + } catch (error) { this._logService.error('[McpGatewayService] Tool call invocation failed', error); throw new JsonRpcError(MCP_INVALID_PARAMS, String(error)); - }); + } } private _handleListTools(): unknown { return this._toolInvoker.listTools() .then(tools => ({ tools })); } + + private async _handleListResources(): Promise { + const serverResults = await this._toolInvoker.listResources(); + const allResources: MCP.Resource[] = []; + for (const { serverIndex, resources } of serverResults) { + for (const resource of resources) { + allResources.push({ + ...resource, + uri: encodeGatewayResourceUri(resource.uri, serverIndex), + }); + } + } + return { resources: allResources }; + } + + private async _handleReadResource(request: IJsonRpcRequest): Promise { + const params = typeof request.params === 'object' && request.params ? request.params as Record : undefined; + if (!params || typeof params.uri !== 'string') { + throw new JsonRpcError(MCP_INVALID_PARAMS, 'Missing resource URI'); + } + + const { serverIndex, originalUri } = decodeGatewayResourceUri(params.uri); + try { + const result = await this._toolInvoker.readResource(serverIndex, originalUri); + return { + contents: result.contents.map(content => ({ + ...content, + uri: encodeGatewayResourceUri(content.uri, serverIndex), + })), + }; + } catch (error) { + this._logService.error('[McpGatewayService] Resource read failed', error); + throw new JsonRpcError(MCP_INVALID_PARAMS, String(error)); + } + } + + private async _handleListResourceTemplates(): Promise { + const serverResults = await this._toolInvoker.listResourceTemplates(); + const allTemplates: MCP.ResourceTemplate[] = []; + for (const { serverIndex, resourceTemplates } of serverResults) { + for (const template of resourceTemplates) { + allTemplates.push({ + ...template, + uriTemplate: encodeGatewayResourceUri(template.uriTemplate, serverIndex), + }); + } + } + return { resourceTemplates: allTemplates }; + } } export function isInitializeMessage(message: JsonRpcMessage | JsonRpcMessage[]): boolean { diff --git a/src/vs/platform/mcp/test/node/mcpGatewaySession.test.ts b/src/vs/platform/mcp/test/node/mcpGatewaySession.test.ts index 945b2877ca348..98712bb96810a 100644 --- a/src/vs/platform/mcp/test/node/mcpGatewaySession.test.ts +++ b/src/vs/platform/mcp/test/node/mcpGatewaySession.test.ts @@ -11,7 +11,7 @@ import { IJsonRpcErrorResponse, IJsonRpcSuccessResponse } from '../../../../base import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { MCP } from '../../common/modelContextProtocol.js'; -import { McpGatewaySession } from '../../node/mcpGatewaySession.js'; +import { decodeGatewayResourceUri, encodeGatewayResourceUri, McpGatewaySession } from '../../node/mcpGatewaySession.js'; class TestServerResponse extends EventEmitter { public statusCode: number | undefined; @@ -48,6 +48,7 @@ suite('McpGatewaySession', () => { function createInvoker() { const onDidChangeTools = new Emitter(); + const onDidChangeResources = new Emitter(); const tools: readonly MCP.Tool[] = [{ name: 'test_tool', description: 'Test tool', @@ -59,20 +60,35 @@ suite('McpGatewaySession', () => { } }]; + const resources: readonly MCP.Resource[] = [{ + uri: 'file:///test/resource.txt', + name: 'resource.txt', + }]; + return { onDidChangeTools, + onDidChangeResources, invoker: { onDidChangeTools: onDidChangeTools.event, + onDidChangeResources: onDidChangeResources.event, listTools: async () => tools, - callTool: async (_name: string, args: Record): Promise => ({ - content: [{ type: 'text', text: `Hello, ${typeof args.name === 'string' ? args.name : 'World'}!` }] - }) + callTool: async (_name: string, args: Record) => ({ + result: { + content: [{ type: 'text' as const, text: `Hello, ${typeof args.name === 'string' ? args.name : 'World'}!` }] + }, + serverIndex: 0, + }), + listResources: async () => [{ serverIndex: 0, resources }], + readResource: async (_serverIndex: number, _uri: string) => ({ + contents: [{ uri: 'file:///test/resource.txt', text: 'hello world', mimeType: 'text/plain' }], + }), + listResourceTemplates: async () => [{ serverIndex: 0, resourceTemplates: [{ uriTemplate: 'file:///test/{name}', name: 'Test Template' }] }], } }; } test('returns initialize result', async () => { - const { invoker, onDidChangeTools } = createInvoker(); + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); const session = new McpGatewaySession('session-1', new NullLogService(), () => { }, invoker); const responses = await session.handleIncoming({ @@ -93,10 +109,11 @@ suite('McpGatewaySession', () => { assert.strictEqual((response.result as { protocolVersion: string }).protocolVersion, '2025-11-25'); session.dispose(); onDidChangeTools.dispose(); + onDidChangeResources.dispose(); }); test('rejects non-initialize requests before initialized notification', async () => { - const { invoker, onDidChangeTools } = createInvoker(); + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); const session = new McpGatewaySession('session-2', new NullLogService(), () => { }, invoker); const responses = await session.handleIncoming({ @@ -112,10 +129,11 @@ suite('McpGatewaySession', () => { assert.strictEqual(response.error.code, -32600); session.dispose(); onDidChangeTools.dispose(); + onDidChangeResources.dispose(); }); test('serves tools/list and tools/call after initialized notification', async () => { - const { invoker, onDidChangeTools } = createInvoker(); + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); const session = new McpGatewaySession('session-3', new NullLogService(), () => { }, invoker); await session.handleIncoming({ jsonrpc: '2.0', id: 1, method: 'initialize' }); @@ -145,10 +163,11 @@ suite('McpGatewaySession', () => { assert.strictEqual(text, 'Hello, VS Code!'); session.dispose(); onDidChangeTools.dispose(); + onDidChangeResources.dispose(); }); test('broadcasts notifications to attached SSE clients', async () => { - const { invoker, onDidChangeTools } = createInvoker(); + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); const session = new McpGatewaySession('session-4', new NullLogService(), () => { }, invoker); const response = new TestServerResponse(); @@ -161,12 +180,14 @@ suite('McpGatewaySession', () => { assert.ok(response.writes.some(chunk => chunk.includes(': connected'))); assert.ok(response.writes.some(chunk => chunk.includes('event: message'))); assert.ok(response.writes.some(chunk => chunk.includes('notifications/tools/list_changed'))); + assert.ok(response.writes.some(chunk => chunk.includes('notifications/resources/list_changed'))); session.dispose(); onDidChangeTools.dispose(); + onDidChangeResources.dispose(); }); test('emits list changed on tool invoker changes', async () => { - const { invoker, onDidChangeTools } = createInvoker(); + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); const session = new McpGatewaySession('session-5', new NullLogService(), () => { }, invoker); const response = new TestServerResponse(); @@ -181,10 +202,11 @@ suite('McpGatewaySession', () => { assert.ok(response.writes.slice(writesBefore).some(chunk => chunk.includes('notifications/tools/list_changed'))); session.dispose(); onDidChangeTools.dispose(); + onDidChangeResources.dispose(); }); test('disposes attached SSE clients and callback', () => { - const { invoker, onDidChangeTools } = createInvoker(); + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); let disposed = false; const session = new McpGatewaySession('session-6', new NullLogService(), () => { disposed = true; @@ -197,5 +219,145 @@ suite('McpGatewaySession', () => { assert.strictEqual(response.writableEnded, true); assert.strictEqual(disposed, true); onDidChangeTools.dispose(); + onDidChangeResources.dispose(); + }); + + test('emits resources list changed on resource invoker changes', async () => { + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); + const session = new McpGatewaySession('session-7', new NullLogService(), () => { }, invoker); + const response = new TestServerResponse(); + + session.attachSseClient({} as http.IncomingMessage, response as unknown as http.ServerResponse); + await session.handleIncoming({ jsonrpc: '2.0', id: 1, method: 'initialize' }); + await session.handleIncoming({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + const writesBefore = response.writes.length; + onDidChangeResources.fire(); + + assert.ok(response.writes.length > writesBefore); + assert.ok(response.writes.slice(writesBefore).some(chunk => chunk.includes('notifications/resources/list_changed'))); + session.dispose(); + onDidChangeTools.dispose(); + onDidChangeResources.dispose(); + }); + + test('serves resources/list with encoded URIs', async () => { + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); + const session = new McpGatewaySession('session-8', new NullLogService(), () => { }, invoker); + + await session.handleIncoming({ jsonrpc: '2.0', id: 1, method: 'initialize' }); + await session.handleIncoming({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + const responses = await session.handleIncoming({ jsonrpc: '2.0', id: 2, method: 'resources/list' }); + const response = responses[0] as IJsonRpcSuccessResponse; + const resources = (response.result as { resources: Array<{ uri: string; name: string }> }).resources; + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].uri, 'file://-0/test/resource.txt'); + assert.strictEqual(resources[0].name, 'resource.txt'); + session.dispose(); + onDidChangeTools.dispose(); + onDidChangeResources.dispose(); + }); + + test('serves resources/read with URI decoding and re-encoding', async () => { + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); + const session = new McpGatewaySession('session-9', new NullLogService(), () => { }, invoker); + + await session.handleIncoming({ jsonrpc: '2.0', id: 1, method: 'initialize' }); + await session.handleIncoming({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + const responses = await session.handleIncoming({ + jsonrpc: '2.0', + id: 2, + method: 'resources/read', + params: { uri: 'file://-0/test/resource.txt' }, + }); + const response = responses[0] as IJsonRpcSuccessResponse; + const contents = (response.result as { contents: Array<{ uri: string; text: string }> }).contents; + assert.strictEqual(contents.length, 1); + assert.strictEqual(contents[0].uri, 'file://-0/test/resource.txt'); + assert.strictEqual(contents[0].text, 'hello world'); + session.dispose(); + onDidChangeTools.dispose(); + onDidChangeResources.dispose(); + }); + + test('serves resources/templates/list with encoded URI templates', async () => { + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); + const session = new McpGatewaySession('session-10', new NullLogService(), () => { }, invoker); + + await session.handleIncoming({ jsonrpc: '2.0', id: 1, method: 'initialize' }); + await session.handleIncoming({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + const responses = await session.handleIncoming({ jsonrpc: '2.0', id: 2, method: 'resources/templates/list' }); + const response = responses[0] as IJsonRpcSuccessResponse; + const templates = (response.result as { resourceTemplates: Array<{ uriTemplate: string; name: string }> }).resourceTemplates; + assert.strictEqual(templates.length, 1); + assert.strictEqual(templates[0].uriTemplate, 'file://-0/test/{name}'); + assert.strictEqual(templates[0].name, 'Test Template'); + session.dispose(); + onDidChangeTools.dispose(); + onDidChangeResources.dispose(); + }); +}); + +suite('Gateway Resource URI encoding', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('encodes and decodes URI with authority', () => { + const encoded = encodeGatewayResourceUri('https://example.com/resource', 3); + assert.strictEqual(encoded, 'https://example.com-3/resource'); + const decoded = decodeGatewayResourceUri(encoded); + assert.strictEqual(decoded.serverIndex, 3); + assert.strictEqual(decoded.originalUri, 'https://example.com/resource'); + }); + + test('encodes and decodes URI with empty authority', () => { + const encoded = encodeGatewayResourceUri('file:///path/to/file', 0); + assert.strictEqual(encoded, 'file://-0/path/to/file'); + const decoded = decodeGatewayResourceUri(encoded); + assert.strictEqual(decoded.serverIndex, 0); + assert.strictEqual(decoded.originalUri, 'file:///path/to/file'); + }); + + test('encodes and decodes URI with authority containing hyphens', () => { + const encoded = encodeGatewayResourceUri('https://my-server.example.com/res', 12); + assert.strictEqual(encoded, 'https://my-server.example.com-12/res'); + const decoded = decodeGatewayResourceUri(encoded); + assert.strictEqual(decoded.serverIndex, 12); + assert.strictEqual(decoded.originalUri, 'https://my-server.example.com/res'); + }); + + test('encodes and decodes URI with port', () => { + const encoded = encodeGatewayResourceUri('http://localhost:8080/api', 5); + assert.strictEqual(encoded, 'http://localhost:8080-5/api'); + const decoded = decodeGatewayResourceUri(encoded); + assert.strictEqual(decoded.serverIndex, 5); + assert.strictEqual(decoded.originalUri, 'http://localhost:8080/api'); + }); + + test('encodes and decodes URI with query and fragment', () => { + const encoded = encodeGatewayResourceUri('https://example.com/resource?q=1#section', 2); + assert.strictEqual(encoded, 'https://example.com-2/resource?q=1#section'); + const decoded = decodeGatewayResourceUri(encoded); + assert.strictEqual(decoded.serverIndex, 2); + assert.strictEqual(decoded.originalUri, 'https://example.com/resource?q=1#section'); + }); + + test('encodes and decodes custom scheme URIs', () => { + const encoded = encodeGatewayResourceUri('custom://myhost/path', 7); + assert.strictEqual(encoded, 'custom://myhost-7/path'); + const decoded = decodeGatewayResourceUri(encoded); + assert.strictEqual(decoded.serverIndex, 7); + assert.strictEqual(decoded.originalUri, 'custom://myhost/path'); + }); + + test('returns URI unchanged if no scheme match', () => { + const encoded = encodeGatewayResourceUri('not-a-uri', 1); + assert.strictEqual(encoded, 'not-a-uri'); + }); + + test('throws on decode of URI without server index suffix', () => { + assert.throws(() => decodeGatewayResourceUri('https://example.com/resource')); }); }); diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index 9b4701fe419f3..cbff23edc4c94 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -307,7 +307,6 @@ class AccountWidgetContribution extends Disposable implements IWorkbenchContribu when: ContextKeyExpr.or( CONTEXT_UPDATE_STATE.isEqualTo(StateType.Ready), CONTEXT_UPDATE_STATE.isEqualTo(StateType.AvailableForDownload), - CONTEXT_UPDATE_STATE.isEqualTo(StateType.CheckingForUpdates), CONTEXT_UPDATE_STATE.isEqualTo(StateType.Downloading), CONTEXT_UPDATE_STATE.isEqualTo(StateType.Downloaded), CONTEXT_UPDATE_STATE.isEqualTo(StateType.Updating), diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts index 82acbdb1c5be3..6bb6704f60278 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts @@ -4,13 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import './agentFeedbackEditorInputContribution.js'; -import './agentFeedbackGlyphMarginContribution.js'; +import './agentFeedbackLineDecorationContribution.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { AgentFeedbackService, IAgentFeedbackService } from './agentFeedbackService.js'; import { AgentFeedbackAttachmentContribution } from './agentFeedbackAttachment.js'; +import { AgentFeedbackAttachmentWidget } from './agentFeedbackAttachmentWidget.js'; import { AgentFeedbackEditorOverlay } from './agentFeedbackEditorOverlay.js'; import { registerAgentFeedbackEditorActions } from './agentFeedbackEditorActions.js'; +import { IChatAttachmentWidgetRegistry } from '../../../../workbench/contrib/chat/browser/attachments/chatAttachmentWidgetRegistry.js'; +import { IAgentFeedbackVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; registerWorkbenchContribution2(AgentFeedbackEditorOverlay.ID, AgentFeedbackEditorOverlay, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentFeedbackAttachmentContribution.ID, AgentFeedbackAttachmentContribution, WorkbenchPhase.AfterRestored); @@ -18,3 +21,14 @@ registerWorkbenchContribution2(AgentFeedbackAttachmentContribution.ID, AgentFeed registerAgentFeedbackEditorActions(); registerSingleton(IAgentFeedbackService, AgentFeedbackService, InstantiationType.Delayed); + +// Register the custom attachment widget for agentFeedback attachments +class AgentFeedbackAttachmentWidgetContribution { + static readonly ID = 'workbench.contrib.agentFeedbackAttachmentWidgetFactory'; + constructor(@IChatAttachmentWidgetRegistry registry: IChatAttachmentWidgetRegistry) { + registry.registerFactory('agentFeedback', (instantiationService, attachment, options, container) => { + return instantiationService.createInstance(AgentFeedbackAttachmentWidget, attachment as IAgentFeedbackVariableEntry, options, container); + }); + } +} +registerWorkbenchContribution2(AgentFeedbackAttachmentWidgetContribution.ID, AgentFeedbackAttachmentWidgetContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts index 720eb60b9e2ca..e45f96e488d06 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts @@ -14,7 +14,7 @@ import { IAgentFeedbackService, IAgentFeedback } from './agentFeedbackService.js import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { IAgentFeedbackVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; -const ATTACHMENT_ID_PREFIX = 'agentFeedback:'; +export const ATTACHMENT_ID_PREFIX = 'agentFeedback:'; /** * Keeps the "N feedback items" attachment in the chat input in sync with the diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackGlyphMarginContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackGlyphMarginContribution.ts index 6436e692fc2a8..bc7805d4b1720 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackGlyphMarginContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackGlyphMarginContribution.ts @@ -8,7 +8,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { ICodeEditor, IEditorMouseEvent, MouseTargetType } from '../../../../editor/browser/editorBrowser.js'; import { IEditorContribution } from '../../../../editor/common/editorCommon.js'; import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js'; -import { GlyphMarginLane, IModelDeltaDecoration, TrackedRangeStickiness } from '../../../../editor/common/model.js'; +import { IModelDeltaDecoration, TrackedRangeStickiness } from '../../../../editor/common/model.js'; import { ModelDecorationOptions } from '../../../../editor/common/model/textModel.js'; import { Range } from '../../../../editor/common/core/range.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; @@ -20,19 +20,15 @@ import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browse import { getSessionForResource } from './agentFeedbackEditorUtils.js'; import { Selection } from '../../../../editor/common/core/selection.js'; -const GLYPH_MARGIN_LANE = GlyphMarginLane.Left; - const feedbackGlyphDecoration = ModelDecorationOptions.register({ description: 'agent-feedback-glyph', - glyphMarginClassName: `${ThemeIcon.asClassName(Codicon.comment)} agent-feedback-glyph`, - glyphMargin: { position: GLYPH_MARGIN_LANE }, + linesDecorationsClassName: `${ThemeIcon.asClassName(Codicon.comment)} agent-feedback-glyph`, stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, }); const addFeedbackHintDecoration = ModelDecorationOptions.register({ description: 'agent-feedback-add-hint', - glyphMarginClassName: `${ThemeIcon.asClassName(Codicon.add)} agent-feedback-add-hint`, - glyphMargin: { position: GLYPH_MARGIN_LANE }, + linesDecorationsClassName: `${ThemeIcon.asClassName(Codicon.add)} agent-feedback-add-hint`, stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, }); @@ -117,9 +113,10 @@ export class AgentFeedbackGlyphMarginContribution extends Disposable implements return; } + const isLineDecoration = e.target.type === MouseTargetType.GUTTER_LINE_DECORATIONS && !e.target.detail.isAfterLines; + const isContentArea = e.target.type === MouseTargetType.CONTENT_TEXT || e.target.type === MouseTargetType.CONTENT_EMPTY; if (e.target.position - && e.target.type === MouseTargetType.GUTTER_GLYPH_MARGIN - && !e.target.detail.isAfterLines + && (isLineDecoration || isContentArea) && !this._feedbackLines.has(e.target.position.lineNumber) ) { this._updateHintDecoration(e.target.position.lineNumber); @@ -150,7 +147,7 @@ export class AgentFeedbackGlyphMarginContribution extends Disposable implements private _onMouseDown(e: IEditorMouseEvent): void { if (!e.target.position - || e.target.type !== MouseTargetType.GUTTER_GLYPH_MARGIN + || e.target.type !== MouseTargetType.GUTTER_LINE_DECORATIONS || e.target.detail.isAfterLines || !this._sessionResource ) { @@ -174,9 +171,9 @@ export class AgentFeedbackGlyphMarginContribution extends Disposable implements const endColumn = model.getLineLastNonWhitespaceColumn(lineNumber); if (startColumn === 0 || endColumn === 0) { // Empty line - select the whole line range - this._editor.setSelection(new Selection(lineNumber, 1, lineNumber, model.getLineMaxColumn(lineNumber))); + this._editor.setSelection(new Selection(lineNumber, model.getLineMaxColumn(lineNumber), lineNumber, 1)); } else { - this._editor.setSelection(new Selection(lineNumber, startColumn, lineNumber, endColumn)); + this._editor.setSelection(new Selection(lineNumber, endColumn, lineNumber, startColumn)); } this._editor.focus(); } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackLineDecorationContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackLineDecorationContribution.ts new file mode 100644 index 0000000000000..63f29606303a3 --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackLineDecorationContribution.ts @@ -0,0 +1,188 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/agentFeedbackLineDecoration.css'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { ICodeEditor, IEditorMouseEvent, MouseTargetType } from '../../../../editor/browser/editorBrowser.js'; +import { IEditorContribution } from '../../../../editor/common/editorCommon.js'; +import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js'; +import { IModelDeltaDecoration, TrackedRangeStickiness } from '../../../../editor/common/model.js'; +import { ModelDecorationOptions } from '../../../../editor/common/model/textModel.js'; +import { Range } from '../../../../editor/common/core/range.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IAgentFeedbackService } from './agentFeedbackService.js'; +import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { getSessionForResource } from './agentFeedbackEditorUtils.js'; +import { Selection } from '../../../../editor/common/core/selection.js'; + +const feedbackLineDecoration = ModelDecorationOptions.register({ + description: 'agent-feedback-line-decoration', + linesDecorationsClassName: `${ThemeIcon.asClassName(Codicon.comment)} agent-feedback-line-decoration`, + stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, +}); + +const addFeedbackHintDecoration = ModelDecorationOptions.register({ + description: 'agent-feedback-add-hint', + linesDecorationsClassName: `${ThemeIcon.asClassName(Codicon.add)} agent-feedback-add-hint`, + stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, +}); + +export class AgentFeedbackLineDecorationContribution extends Disposable implements IEditorContribution { + + static readonly ID = 'agentFeedback.lineDecorationContribution'; + + private readonly _feedbackDecorations; + + private _hintDecorationId: string | null = null; + private _hintLine = -1; + private _sessionResource: URI | undefined; + private _feedbackLines = new Set(); + + constructor( + private readonly _editor: ICodeEditor, + @IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService, + @IChatEditingService private readonly _chatEditingService: IChatEditingService, + @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, + ) { + super(); + + this._feedbackDecorations = this._editor.createDecorationsCollection(); + + this._store.add(this._agentFeedbackService.onDidChangeFeedback(() => this._updateFeedbackDecorations())); + this._store.add(this._editor.onDidChangeModel(() => this._onModelChanged())); + this._store.add(this._editor.onMouseMove((e: IEditorMouseEvent) => this._onMouseMove(e))); + this._store.add(this._editor.onMouseLeave(() => this._updateHintDecoration(-1))); + this._store.add(this._editor.onMouseDown((e: IEditorMouseEvent) => this._onMouseDown(e))); + + this._resolveSession(); + this._updateFeedbackDecorations(); + } + + private _onModelChanged(): void { + this._updateHintDecoration(-1); + this._resolveSession(); + this._updateFeedbackDecorations(); + } + + private _resolveSession(): void { + const model = this._editor.getModel(); + if (!model) { + this._sessionResource = undefined; + return; + } + this._sessionResource = getSessionForResource(model.uri, this._chatEditingService, this._agentSessionsService); + } + + private _updateFeedbackDecorations(): void { + if (!this._sessionResource) { + this._feedbackDecorations.clear(); + this._feedbackLines.clear(); + return; + } + + const feedbackItems = this._agentFeedbackService.getFeedback(this._sessionResource); + const decorations: IModelDeltaDecoration[] = []; + const lines = new Set(); + + for (const item of feedbackItems) { + const model = this._editor.getModel(); + if (!model || item.resourceUri.toString() !== model.uri.toString()) { + continue; + } + + const line = item.range.startLineNumber; + lines.add(line); + decorations.push({ + range: new Range(line, 1, line, 1), + options: feedbackLineDecoration, + }); + } + + this._feedbackLines = lines; + this._feedbackDecorations.set(decorations); + } + + private _onMouseMove(e: IEditorMouseEvent): void { + if (!this._sessionResource) { + this._updateHintDecoration(-1); + return; + } + + const isLineDecoration = e.target.type === MouseTargetType.GUTTER_LINE_DECORATIONS && !e.target.detail.isAfterLines; + const isContentArea = e.target.type === MouseTargetType.CONTENT_TEXT || e.target.type === MouseTargetType.CONTENT_EMPTY; + if (e.target.position + && (isLineDecoration || isContentArea) + && !this._feedbackLines.has(e.target.position.lineNumber) + ) { + this._updateHintDecoration(e.target.position.lineNumber); + } else { + this._updateHintDecoration(-1); + } + } + + private _updateHintDecoration(line: number): void { + if (line === this._hintLine) { + return; + } + + this._hintLine = line; + this._editor.changeDecorations(accessor => { + if (this._hintDecorationId) { + accessor.removeDecoration(this._hintDecorationId); + this._hintDecorationId = null; + } + if (line !== -1) { + this._hintDecorationId = accessor.addDecoration( + new Range(line, 1, line, 1), + addFeedbackHintDecoration, + ); + } + }); + } + + private _onMouseDown(e: IEditorMouseEvent): void { + if (!e.target.position + || e.target.type !== MouseTargetType.GUTTER_LINE_DECORATIONS + || e.target.detail.isAfterLines + || !this._sessionResource + ) { + return; + } + + const lineNumber = e.target.position.lineNumber; + + // Lines with existing feedback - do nothing + if (this._feedbackLines.has(lineNumber)) { + return; + } + + // Select the line content and focus the editor + const model = this._editor.getModel(); + if (!model) { + return; + } + + const startColumn = model.getLineFirstNonWhitespaceColumn(lineNumber); + const endColumn = model.getLineLastNonWhitespaceColumn(lineNumber); + if (startColumn === 0 || endColumn === 0) { + // Empty line - select the whole line range + this._editor.setSelection(new Selection(lineNumber, model.getLineMaxColumn(lineNumber), lineNumber, 1)); + } else { + this._editor.setSelection(new Selection(lineNumber, endColumn, lineNumber, startColumn)); + } + this._editor.focus(); + } + + override dispose(): void { + this._feedbackDecorations.clear(); + this._updateHintDecoration(-1); + super.dispose(); + } +} + +registerEditorContribution(AgentFeedbackLineDecorationContribution.ID, AgentFeedbackLineDecorationContribution, EditorContributionInstantiation.Eventually); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts index 701495fc188b9..4813a045bafec 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts @@ -13,7 +13,6 @@ import { isEqual } from '../../../../base/common/resources.js'; import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { agentSessionContainsResource, editingEntriesContainResource } from '../../../../workbench/contrib/chat/browser/sessionResourceMatching.js'; -import { IChatWidget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; // --- Types -------------------------------------------------------------------- @@ -83,8 +82,6 @@ export interface IAgentFeedbackService { // --- Implementation ----------------------------------------------------------- -const AGENT_FEEDBACK_ATTACHMENT_ID_PREFIX = 'agentFeedback:'; - export class AgentFeedbackService extends Disposable implements IAgentFeedbackService { declare readonly _serviceBrand: undefined; @@ -103,41 +100,8 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe constructor( @IChatEditingService private readonly _chatEditingService: IChatEditingService, @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, - @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, ) { super(); - - this._registerChatWidgetListeners(); - } - - private _registerChatWidgetListeners(): void { - for (const widget of this._chatWidgetService.getAllWidgets()) { - this._registerWidgetListeners(widget); - } - - this._store.add(this._chatWidgetService.onDidAddWidget(widget => { - this._registerWidgetListeners(widget); - })); - } - - private _registerWidgetListeners(widget: IChatWidget): void { - this._store.add(widget.attachmentModel.onDidChange(e => { - for (const deletedId of e.deleted) { - if (!deletedId.startsWith(AGENT_FEEDBACK_ATTACHMENT_ID_PREFIX)) { - continue; - } - - const sessionResourceString = deletedId.slice(AGENT_FEEDBACK_ATTACHMENT_ID_PREFIX.length); - if (!sessionResourceString) { - continue; - } - - const sessionResource = URI.parse(sessionResourceString); - if (this.getFeedback(sessionResource).length > 0) { - this.clearFeedback(sessionResource); - } - } - })); } addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string): IAgentFeedback { diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackGlyphMargin.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackGlyphMargin.css index 16aac443e4802..33bf495578f92 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackGlyphMargin.css +++ b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackGlyphMargin.css @@ -3,24 +3,23 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -.monaco-editor .glyph-margin-widgets .cgmr.agent-feedback-glyph, -.monaco-editor .glyph-margin-widgets .cgmr.agent-feedback-add-hint { +.monaco-editor .agent-feedback-glyph, +.monaco-editor .agent-feedback-add-hint { border-radius: 3px; display: flex !important; align-items: center; justify-content: center; } -.monaco-editor .glyph-margin-widgets .cgmr.agent-feedback-glyph { - background-color: var(--vscode-editorGutter-commentGlyphForeground, var(--vscode-icon-foreground)); - color: var(--vscode-editor-background); +.monaco-editor .agent-feedback-glyph { + background-color: var(--vscode-toolbar-hoverBackground); } -.monaco-editor .glyph-margin-widgets .cgmr.agent-feedback-add-hint { +.monaco-editor .agent-feedback-add-hint { background-color: var(--vscode-toolbar-hoverBackground); opacity: 0.7; } -.monaco-editor .glyph-margin-widgets .cgmr.agent-feedback-add-hint:hover { +.monaco-editor .agent-feedback-add-hint:hover { opacity: 1; } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackLineDecoration.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackLineDecoration.css new file mode 100644 index 0000000000000..890791c597367 --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackLineDecoration.css @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-editor .agent-feedback-line-decoration, +.monaco-editor .agent-feedback-add-hint { + border-radius: 3px; + display: flex !important; + align-items: center; + justify-content: center; +} + +.monaco-editor .agent-feedback-line-decoration { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.monaco-editor .agent-feedback-add-hint { + background-color: var(--vscode-toolbar-hoverBackground); + opacity: 0.7; +} + +.monaco-editor .agent-feedback-add-hint:hover { + opacity: 1; +} diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index ca42f1b451575..25ae3a3f71e86 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -7,27 +7,27 @@ import * as dom from '../../../../base/browser/dom.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Emitter } from '../../../../base/common/event.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { isObject } from '../../../../base/common/types.js'; import { localize } from '../../../../nls.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IQuickInputService, IQuickPickItem, IQuickPickItemWithResource } from '../../../../platform/quickinput/common/quickInput.js'; -import { AnythingQuickAccessProviderRunOptions } from '../../../../platform/quickinput/common/quickAccess.js'; +import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { basename } from '../../../../base/common/resources.js'; +import { Schemas } from '../../../../base/common/network.js'; -import { AnythingQuickAccessProvider } from '../../../../workbench/contrib/search/browser/anythingQuickAccess.js'; import { IChatRequestVariableEntry, OmittedState } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; -import { isSupportedChatFileScheme } from '../../../../workbench/contrib/chat/common/constants.js'; import { resizeImage } from '../../../../workbench/contrib/chat/browser/chatImageUtils.js'; import { imageToHash, isImage } from '../../../../workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.js'; import { getPathForFile } from '../../../../platform/dnd/browser/dnd.js'; +import { getExcludes, ISearchConfiguration, ISearchService, QueryType } from '../../../../workbench/services/search/common/search.js'; /** * Manages context attachments for the sessions new-chat widget. @@ -36,6 +36,7 @@ import { getPathForFile } from '../../../../platform/dnd/browser/dnd.js'; * - File picker via quick access ("Files and Open Folders...") * - Image from Clipboard * - Drag and drop files + * - Paste images from clipboard (Ctrl/Cmd+V) */ export class NewChatContextAttachments extends Disposable { @@ -51,12 +52,14 @@ export class NewChatContextAttachments extends Disposable { } constructor( - @IInstantiationService private readonly instantiationService: IInstantiationService, @IQuickInputService private readonly quickInputService: IQuickInputService, @ITextModelService private readonly textModelService: ITextModelService, @IFileService private readonly fileService: IFileService, @IClipboardService private readonly clipboardService: IClipboardService, @IFileDialogService private readonly fileDialogService: IFileDialogService, + @ILabelService private readonly labelService: ILabelService, + @ISearchService private readonly searchService: ISearchService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); } @@ -168,50 +171,241 @@ export class NewChatContextAttachments extends Disposable { })); } + // --- Paste --- + + registerPasteHandler(element: HTMLElement): void { + const supportedMimeTypes = [ + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/bmp', + 'image/gif', + 'image/tiff' + ]; + + this._register(dom.addDisposableListener(element, dom.EventType.PASTE, async (e: ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) { + return; + } + + // Check synchronously for image data before any async work + // so preventDefault stops the editor from inserting text. + let imageFile: File | undefined; + for (const item of Array.from(items)) { + if (!item.type.startsWith('image/') || !supportedMimeTypes.includes(item.type)) { + continue; + } + const file = item.getAsFile(); + if (file) { + imageFile = file; + break; + } + } + + if (!imageFile) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + const arrayBuffer = await imageFile.arrayBuffer(); + const data = new Uint8Array(arrayBuffer); + if (!isImage(data)) { + return; + } + + const resizedData = await resizeImage(data, imageFile.type); + const displayName = this._getUniqueImageName(); + + this._addAttachments({ + id: await imageToHash(resizedData), + name: displayName, + fullName: displayName, + value: resizedData, + kind: 'image', + }); + }, true)); + } + // --- Picker --- - showPicker(): void { - // Build addition picks for the quick access - const additionPicks: IQuickPickItem[] = []; + showPicker(folderUri?: URI): void { + const picker = this.quickInputService.createQuickPick({ useSeparators: true }); + const disposables = new DisposableStore(); + picker.placeholder = localize('chatContext.attach.placeholder', "Attach as context..."); + picker.matchOnDescription = true; + picker.sortByLabel = false; + + const staticPicks: (IQuickPickItem | IQuickPickSeparator)[] = [ + { + label: localize('files', "Files..."), + iconClass: ThemeIcon.asClassName(Codicon.file), + id: 'sessions.filesAndFolders', + }, + { + label: localize('imageFromClipboard', "Image from Clipboard"), + iconClass: ThemeIcon.asClassName(Codicon.fileMedia), + id: 'sessions.imageFromClipboard', + }, + ]; + + picker.items = staticPicks; + picker.show(); - // "Files and Open Folders..." pick - opens a file dialog - additionPicks.push({ - label: localize('filesAndFolders', "Files and Open Folders..."), - iconClass: ThemeIcon.asClassName(Codicon.file), - id: 'sessions.filesAndFolders', - }); + if (folderUri) { + let searchCts: CancellationTokenSource | undefined; + let debounceTimer: ReturnType | undefined; - // "Image from Clipboard" pick - additionPicks.push({ - label: localize('imageFromClipboard', "Image from Clipboard"), - iconClass: ThemeIcon.asClassName(Codicon.fileMedia), - id: 'sessions.imageFromClipboard', - }); + const runSearch = (filePattern?: string) => { + searchCts?.dispose(true); + searchCts = new CancellationTokenSource(); + const token = searchCts.token; + + picker.busy = true; + this._collectFilePicks(folderUri, filePattern, token).then(filePicks => { + if (token.isCancellationRequested) { + return; + } + picker.busy = false; + if (filePicks.length > 0) { + picker.items = [ + ...staticPicks, + { type: 'separator', label: basename(folderUri) }, + ...filePicks, + ]; + } else { + picker.items = staticPicks; + } + }); + }; + + // Initial search (no filter) + runSearch(); - const providerOptions: AnythingQuickAccessProviderRunOptions = { - filter: (pick) => { - if (_isQuickPickItemWithResource(pick) && pick.resource) { - return this.instantiationService.invokeFunction(accessor => isSupportedChatFileScheme(accessor, pick.resource!.scheme)); + // Re-search on user input with debounce + disposables.add(picker.onDidChangeValue(value => { + if (debounceTimer) { + clearTimeout(debounceTimer); } - return true; - }, - additionPicks, - handleAccept: async (item: IQuickPickItem) => { - if (item.id === 'sessions.filesAndFolders') { - await this._handleFileDialog(); - } else if (item.id === 'sessions.imageFromClipboard') { - await this._handleClipboardImage(); - } else { - await this._handleFilePick(item as IQuickPickItemWithResource); + debounceTimer = setTimeout(() => runSearch(value || undefined), 200); + })); + + disposables.add({ dispose: () => { searchCts?.dispose(true); if (debounceTimer) { clearTimeout(debounceTimer); } } }); + } + + disposables.add(picker.onDidAccept(async () => { + const [selected] = picker.selectedItems; + if (!selected) { + picker.hide(); + return; + } + + picker.hide(); + + if (selected.id === 'sessions.filesAndFolders') { + await this._handleFileDialog(); + } else if (selected.id === 'sessions.imageFromClipboard') { + await this._handleClipboardImage(); + } else if (selected.id) { + await this._attachFileUri(URI.parse(selected.id), selected.label); + } + })); + + disposables.add(picker.onDidHide(() => { + picker.dispose(); + disposables.dispose(); + })); + } + + private async _collectFilePicks(rootUri: URI, filePattern?: string, token?: CancellationToken): Promise { + const maxFiles = 200; + + // For local file:// URIs, use the search service which respects .gitignore and excludes + if (rootUri.scheme === Schemas.file || rootUri.scheme === Schemas.vscodeRemote) { + return this._collectFilePicksViaSearch(rootUri, maxFiles, filePattern, token); + } + + // For virtual filesystems (e.g. github-remote-file://), walk the tree via IFileService + return this._collectFilePicksViaFileService(rootUri, maxFiles, filePattern); + } + + private async _collectFilePicksViaSearch(rootUri: URI, maxFiles: number, filePattern?: string, token?: CancellationToken): Promise { + const excludePattern = getExcludes(this.configurationService.getValue({ resource: rootUri })); + + try { + const searchResult = await this.searchService.fileSearch({ + folderQueries: [{ + folder: rootUri, + disregardIgnoreFiles: false, + }], + type: QueryType.File, + filePattern: filePattern || '', + excludePattern, + sortByScore: true, + maxResults: maxFiles, + }, token); + + return searchResult.results.map(result => ({ + label: basename(result.resource), + description: this.labelService.getUriLabel(result.resource, { relative: true }), + iconClass: ThemeIcon.asClassName(Codicon.file), + id: result.resource.toString(), + } satisfies IQuickPickItem)); + } catch { + return []; + } + } + + private async _collectFilePicksViaFileService(rootUri: URI, maxFiles: number, filePattern?: string): Promise { + const picks: IQuickPickItem[] = []; + const patternLower = filePattern?.toLowerCase(); + const maxDepth = 10; + + const collect = async (uri: URI, depth: number): Promise => { + if (picks.length >= maxFiles || depth > maxDepth) { + return; + } + + try { + const stat = await this.fileService.resolve(uri); + if (!stat.children) { + return; } + + const children = stat.children.slice().sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + + for (const child of children) { + if (picks.length >= maxFiles) { + break; + } + if (child.isDirectory) { + await collect(child.resource, depth + 1); + } else { + if (patternLower && !child.name.toLowerCase().includes(patternLower)) { + continue; + } + picks.push({ + label: child.name, + description: this.labelService.getUriLabel(child.resource, { relative: true }), + iconClass: ThemeIcon.asClassName(Codicon.file), + id: child.resource.toString(), + }); + } + } + } catch { + // ignore errors for individual directories } }; - this.quickInputService.quickAccess.show('', { - enabledProviderPrefixes: [AnythingQuickAccessProvider.PREFIX], - placeholder: localize('chatContext.attach.placeholder', "Attach as context..."), - providerOptions, - }); + await collect(rootUri, 0); + return picks; } private async _handleFileDialog(): Promise { @@ -230,13 +424,6 @@ export class NewChatContextAttachments extends Disposable { } } - private async _handleFilePick(pick: IQuickPickItemWithResource): Promise { - if (!pick.resource) { - return; - } - await this._attachFileUri(pick.resource, pick.label); - } - private async _attachFileUri(uri: URI, name: string): Promise { if (/\.(png|jpg|jpeg|bmp|gif|tiff)$/i.test(uri.path)) { const readFile = await this.fileService.readFile(uri); @@ -274,10 +461,12 @@ export class NewChatContextAttachments extends Disposable { return; } + const displayName = this._getUniqueImageName(); + this._addAttachments({ id: await imageToHash(imageData), - name: localize('pastedImage', "Pasted Image"), - fullName: localize('pastedImage', "Pasted Image"), + name: displayName, + fullName: displayName, value: imageData, kind: 'image', }); @@ -285,6 +474,15 @@ export class NewChatContextAttachments extends Disposable { // --- State management --- + private _getUniqueImageName(): string { + const baseName = localize('pastedImage', "Pasted Image"); + let name = baseName; + for (let i = 2; this._attachedContext.some(a => a.name === name); i++) { + name = `${baseName} ${i}`; + } + return name; + } + private _addAttachments(...entries: IChatRequestVariableEntry[]): void { for (const entry of entries) { if (!this._attachedContext.some(e => e.id === entry.id)) { @@ -310,9 +508,3 @@ export class NewChatContextAttachments extends Disposable { this._onDidChangeContext.fire(); } } - -function _isQuickPickItemWithResource(obj: unknown): obj is IQuickPickItemWithResource { - return ( - isObject(obj) - && URI.isUri((obj as IQuickPickItemWithResource).resource)); -} diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index eae4d116d1b09..f2696aecbeefd 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -58,6 +58,7 @@ import { getSimpleEditorOptions } from '../../../../workbench/contrib/codeEditor import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { isString } from '../../../../base/common/types.js'; import { NewChatContextAttachments } from './newChatContextAttachments.js'; +import { GITHUB_REMOTE_FILE_SCHEME } from '../../fileTreeView/browser/githubFileSystemProvider.js'; // #region --- Target Config --- @@ -319,6 +320,7 @@ class NewChatWidget extends Disposable { // Input area inside the input slot const inputArea = dom.$('.sessions-chat-input-area'); this._contextAttachments.registerDropTarget(inputArea); + this._contextAttachments.registerPasteHandler(inputArea); // Attachments row (plus button + pills) inside input area, above editor const attachRow = dom.append(inputArea, dom.$('.sessions-chat-attach-row')); @@ -441,7 +443,37 @@ class NewChatWidget extends Disposable { attachButton.title = localize('addContext', "Add Context..."); attachButton.ariaLabel = localize('addContext', "Add Context..."); dom.append(attachButton, renderIcon(Codicon.add)); - this._register(dom.addDisposableListener(attachButton, dom.EventType.CLICK, () => this._contextAttachments.showPicker())); + this._register(dom.addDisposableListener(attachButton, dom.EventType.CLICK, () => { + this._contextAttachments.showPicker(this._getContextFolderUri()); + })); + } + + /** + * Returns the folder URI for the context picker based on the current target. + * Local targets use the workspace folder; cloud targets construct a github-remote-file:// URI. + */ + private _getContextFolderUri(): URI | undefined { + const target = this._getEffectiveTarget(); + + if (!target || target === AgentSessionProviders.Local || target === AgentSessionProviders.Background) { + return this._selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri; + } + + // For cloud targets, look for a repository option in the selected options + for (const [groupId, option] of this._selectedOptions) { + if (isRepoOrFolderGroup({ id: groupId, name: groupId, items: [] })) { + const nwo = option.id; // e.g. "owner/repo" + if (nwo && nwo.includes('/')) { + return URI.from({ + scheme: GITHUB_REMOTE_FILE_SCHEME, + authority: 'github', + path: `/${nwo}/HEAD`, + }); + } + } + } + + return undefined; } private _createBottomToolbar(container: HTMLElement): void { diff --git a/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts new file mode 100644 index 0000000000000..1d67b91d1b4b3 --- /dev/null +++ b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { GitHubFileSystemProvider, GITHUB_REMOTE_FILE_SCHEME } from './githubFileSystemProvider.js'; + +// --- View registration is currently disabled in favor of the "Add Context" picker. +// The Files view will be re-enabled once we finalize the sessions auxiliary bar layout. + +// --- Session Repo FileSystem Provider Registration + +class GitHubFileSystemProviderContribution extends Disposable { + + static readonly ID = 'workbench.contrib.githubFileSystemProvider'; + + constructor( + @IFileService fileService: IFileService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + const provider = this._register(instantiationService.createInstance(GitHubFileSystemProvider)); + this._register(fileService.registerProvider(GITHUB_REMOTE_FILE_SCHEME, provider)); + } +} + +registerWorkbenchContribution2( + GitHubFileSystemProviderContribution.ID, + GitHubFileSystemProviderContribution, + WorkbenchPhase.AfterRestored +); diff --git a/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts new file mode 100644 index 0000000000000..0ef758704325e --- /dev/null +++ b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts @@ -0,0 +1,577 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/fileTreeView.css'; +import * as dom from '../../../../base/browser/dom.js'; +import { IAsyncDataSource, ITreeNode } from '../../../../base/browser/ui/tree/tree.js'; +import { ICompressedTreeNode } from '../../../../base/browser/ui/tree/compressedObjectTreeModel.js'; +import { ICompressibleTreeRenderer } from '../../../../base/browser/ui/tree/objectTree.js'; +import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derived, IObservable, observableFromEvent } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { FileKind, IFileService, IFileStat } from '../../../../platform/files/common/files.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { WorkbenchCompressibleAsyncDataTree } from '../../../../platform/list/browser/listService.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IResourceLabel, ResourceLabels } from '../../../../workbench/browser/labels.js'; +import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; +import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; +import { IViewDescriptorService } from '../../../../workbench/common/views.js'; +import { createFileIconThemableTreeContainerScope } from '../../../../workbench/contrib/files/browser/views/explorerView.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; +import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../../workbench/services/editor/common/editorService.js'; +import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; +import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { ISessionsManagementService, IActiveSessionItem } from '../../sessions/browser/sessionsManagementService.js'; +import { GITHUB_REMOTE_FILE_SCHEME } from './githubFileSystemProvider.js'; +import { basename } from '../../../../base/common/path.js'; +import { isEqual } from '../../../../base/common/resources.js'; + +const $ = dom.$; + +// --- Constants + +export const FILE_TREE_VIEW_CONTAINER_ID = 'workbench.view.agentSessions.fileTreeContainer'; +export const FILE_TREE_VIEW_ID = 'workbench.view.agentSessions.fileTree'; + +// --- Tree Item + +interface IFileTreeItem { + readonly uri: URI; + readonly name: string; + readonly isDirectory: boolean; +} + +// --- Data Source + +class FileTreeDataSource implements IAsyncDataSource { + + constructor( + private readonly fileService: IFileService, + private readonly logService: ILogService, + ) { } + + hasChildren(element: URI | IFileTreeItem): boolean { + if (URI.isUri(element)) { + return true; // root + } + return element.isDirectory; + } + + async getChildren(element: URI | IFileTreeItem): Promise { + const uri = URI.isUri(element) ? element : element.uri; + + try { + const stat = await this.fileService.resolve(uri); + if (!stat.children) { + return []; + } + + return stat.children + .map((child: IFileStat): IFileTreeItem => ({ + uri: child.resource, + name: child.name, + isDirectory: child.isDirectory, + })) + .sort((a, b) => { + // Directories first, then alphabetical + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + } catch (e) { + this.logService.warn(`[FileTreeView] Error fetching children for ${uri.toString()}:`, e); + return []; + } + } +} + +// --- Delegate + +class FileTreeDelegate implements IListVirtualDelegate { + getHeight(): number { + return 22; + } + + getTemplateId(): string { + return FileTreeRenderer.TEMPLATE_ID; + } +} + +// --- Renderer + +interface IFileTreeTemplate { + readonly label: IResourceLabel; + readonly templateDisposables: DisposableStore; +} + +class FileTreeRenderer implements ICompressibleTreeRenderer { + static readonly TEMPLATE_ID = 'fileTreeRenderer'; + readonly templateId = FileTreeRenderer.TEMPLATE_ID; + + constructor( + private readonly labels: ResourceLabels, + @ILabelService private readonly labelService: ILabelService, + ) { } + + renderTemplate(container: HTMLElement): IFileTreeTemplate { + const templateDisposables = new DisposableStore(); + const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true, supportIcons: true })); + return { label, templateDisposables }; + } + + renderElement(node: ITreeNode, _index: number, templateData: IFileTreeTemplate): void { + const element = node.element; + templateData.label.element.style.display = 'flex'; + templateData.label.setFile(element.uri, { + fileKind: element.isDirectory ? FileKind.FOLDER : FileKind.FILE, + hidePath: true, + }); + } + + renderCompressedElements(node: ITreeNode, void>, _index: number, templateData: IFileTreeTemplate): void { + const compressed = node.element; + const lastElement = compressed.elements[compressed.elements.length - 1]; + + templateData.label.element.style.display = 'flex'; + + const label = compressed.elements.map(e => e.name); + templateData.label.setResource({ resource: lastElement.uri, name: label }, { + fileKind: lastElement.isDirectory ? FileKind.FOLDER : FileKind.FILE, + separator: this.labelService.getSeparator(lastElement.uri.scheme), + }); + } + + disposeTemplate(templateData: IFileTreeTemplate): void { + templateData.templateDisposables.dispose(); + } +} + +// --- Compression Delegate + +class FileTreeCompressionDelegate { + isIncompressible(element: IFileTreeItem): boolean { + return !element.isDirectory; + } +} + +// --- View Pane + +export class FileTreeViewPane extends ViewPane { + + private bodyContainer: HTMLElement | undefined; + private welcomeContainer: HTMLElement | undefined; + private treeContainer: HTMLElement | undefined; + + private tree: WorkbenchCompressibleAsyncDataTree | undefined; + + private readonly renderDisposables = this._register(new DisposableStore()); + private readonly treeInputDisposable = this._register(new MutableDisposable()); + + private currentBodyHeight = 0; + private currentBodyWidth = 0; + + /** + * Observable that tracks the root URI for the file tree. + * - For background sessions: the worktree or repository local path + * - For cloud sessions: a github-remote-file:// URI derived from the session's repository metadata + * - For local sessions: the workspace folder + */ + private readonly treeRootUri: IObservable; + + constructor( + options: IViewPaneOptions, + @IKeybindingService keybindingService: IKeybindingService, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IInstantiationService instantiationService: IInstantiationService, + @IOpenerService openerService: IOpenerService, + @IThemeService themeService: IThemeService, + @IHoverService hoverService: IHoverService, + @IFileService private readonly fileService: IFileService, + @IEditorService private readonly editorService: IEditorService, + @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @ILogService private readonly logService: ILogService, + ) { + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); + + // Track active session changes AND session model updates (metadata/changes can arrive later) + const sessionsChangedSignal = observableFromEvent( + this, + this.agentSessionsService.model.onDidChangeSessions, + () => ({}), + ); + + this.treeRootUri = derived(reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); + sessionsChangedSignal.read(reader); // re-evaluate when sessions data updates + return this.resolveTreeRoot(activeSession); + }); + } + + /** + * Determines the root URI for the file tree based on the active session type. + * Tries multiple data sources: IActiveSessionItem fields, agent session model metadata, + * and file change URIs as a last resort. + */ + private resolveTreeRoot(activeSession: IActiveSessionItem | undefined): URI | undefined { + if (!activeSession) { + return undefined; + } + + const sessionType = getChatSessionType(activeSession.resource); + + // 1. Try the direct worktree/repository fields from IActiveSessionItem + if (activeSession.worktree) { + this.logService.info(`[FileTreeView] Using worktree: ${activeSession.worktree.toString()}`); + return activeSession.worktree; + } + if (activeSession.repository && activeSession.repository.scheme === 'file') { + this.logService.info(`[FileTreeView] Using repository: ${activeSession.repository.toString()}`); + return activeSession.repository; + } + + // 2. Query the agent session model directly for metadata + const agentSession = this.agentSessionsService.getSession(activeSession.resource); + if (agentSession?.metadata) { + const metadata = agentSession.metadata; + + // Background sessions: local paths (try multiple known metadata keys) + const workingDir = metadata.workingDirectoryPath as string | undefined; + if (workingDir) { + this.logService.info(`[FileTreeView] Using metadata.workingDirectoryPath: ${workingDir}`); + return URI.file(workingDir); + } + const worktreePath = metadata.worktreePath as string | undefined; + if (worktreePath) { + this.logService.info(`[FileTreeView] Using metadata.worktreePath: ${worktreePath}`); + return URI.file(worktreePath); + } + const repositoryPath = metadata.repositoryPath as string | undefined; + if (repositoryPath) { + this.logService.info(`[FileTreeView] Using metadata.repositoryPath: ${repositoryPath}`); + return URI.file(repositoryPath); + } + + // Cloud sessions: GitHub repo info in metadata + const repoUri = this.extractRepoUriFromMetadata(metadata); + if (repoUri) { + return repoUri; + } + } + + // 3. For cloud/remote sessions: try to infer repo from file change URIs + if (sessionType === AgentSessionProviders.Cloud || sessionType === AgentSessionProviders.Codex) { + const repoUri = this.inferRepoFromChanges(activeSession.resource); + if (repoUri) { + this.logService.info(`[FileTreeView] Inferred repo from changes: ${repoUri.toString()}`); + return repoUri; + } + } + + // 4. Try to parse the repository URI as a GitHub URL + if (activeSession.repository) { + const repoStr = activeSession.repository.toString(); + const parsed = this.parseGitHubUrl(repoStr); + if (parsed) { + this.logService.info(`[FileTreeView] Parsed repository URI as GitHub: ${parsed.owner}/${parsed.repo}`); + return URI.from({ + scheme: GITHUB_REMOTE_FILE_SCHEME, + authority: 'github', + path: `/${parsed.owner}/${parsed.repo}/HEAD`, + }); + } + } + + this.logService.trace(`[FileTreeView] No tree root resolved for session ${activeSession.resource.toString()} (type: ${sessionType})`); + return undefined; + } + + /** + * Extracts a github-remote-file:// URI from session metadata, trying various known fields. + */ + private extractRepoUriFromMetadata(metadata: { readonly [key: string]: unknown }): URI | undefined { + // repositoryNwo: "owner/repo" + const repositoryNwo = metadata.repositoryNwo as string | undefined; + if (repositoryNwo && repositoryNwo.includes('/')) { + this.logService.info(`[FileTreeView] Using metadata.repositoryNwo: ${repositoryNwo}`); + return URI.from({ + scheme: GITHUB_REMOTE_FILE_SCHEME, + authority: 'github', + path: `/${repositoryNwo}/HEAD`, + }); + } + + // repositoryUrl: "https://github.com/owner/repo" + const repositoryUrl = metadata.repositoryUrl as string | undefined; + if (repositoryUrl) { + const parsed = this.parseGitHubUrl(repositoryUrl); + if (parsed) { + this.logService.info(`[FileTreeView] Using metadata.repositoryUrl: ${repositoryUrl}`); + return URI.from({ + scheme: GITHUB_REMOTE_FILE_SCHEME, + authority: 'github', + path: `/${parsed.owner}/${parsed.repo}/HEAD`, + }); + } + } + + // repository: could be "owner/repo" or a URL + const repository = metadata.repository as string | undefined; + if (repository) { + if (repository.includes('/') && !repository.includes(':')) { + // Looks like "owner/repo" + this.logService.info(`[FileTreeView] Using metadata.repository as nwo: ${repository}`); + return URI.from({ + scheme: GITHUB_REMOTE_FILE_SCHEME, + authority: 'github', + path: `/${repository}/HEAD`, + }); + } + const parsed = this.parseGitHubUrl(repository); + if (parsed) { + this.logService.info(`[FileTreeView] Using metadata.repository as URL: ${repository}`); + return URI.from({ + scheme: GITHUB_REMOTE_FILE_SCHEME, + authority: 'github', + path: `/${parsed.owner}/${parsed.repo}/HEAD`, + }); + } + } + + return undefined; + } + + /** + * Attempts to infer the repository from the session's file change URIs. + * Cloud sessions have changes with URIs that reveal the repository. + */ + private inferRepoFromChanges(sessionResource: URI): URI | undefined { + const agentSession = this.agentSessionsService.getSession(sessionResource); + if (!agentSession?.changes || !(agentSession.changes instanceof Array)) { + return undefined; + } + + for (const change of agentSession.changes) { + const fileUri = isIChatSessionFileChange2(change) + ? (change.modifiedUri ?? change.uri) + : change.modifiedUri; + + if (!fileUri) { + continue; + } + + const parsed = this.parseRepoFromFileUri(fileUri); + if (parsed) { + return URI.from({ + scheme: GITHUB_REMOTE_FILE_SCHEME, + authority: 'github', + path: `/${parsed.owner}/${parsed.repo}/${parsed.ref}`, + }); + } + } + + return undefined; + } + + /** + * Tries to extract GitHub owner/repo from a file change URI. + * Handles various URI formats used by cloud sessions. + */ + private parseRepoFromFileUri(uri: URI): { owner: string; repo: string; ref: string } | undefined { + // Pattern: vscode-vfs://github/{owner}/{repo}/... + if (uri.authority === 'github' || uri.authority?.startsWith('github')) { + const parts = uri.path.split('/').filter(Boolean); + if (parts.length >= 2) { + return { owner: parts[0], repo: parts[1], ref: 'HEAD' }; + } + } + + // Pattern: github://{owner}/{repo}/... or github1s://{owner}/{repo}/... + if (uri.scheme === 'github' || uri.scheme === 'github1s') { + const parts = uri.authority ? uri.authority.split('/') : uri.path.split('/').filter(Boolean); + if (parts.length >= 2) { + return { owner: parts[0], repo: parts[1], ref: 'HEAD' }; + } + } + + // Pattern: https://github.com/{owner}/{repo}/... + return this.parseGitHubUrl(uri.toString()); + } + + private parseGitHubUrl(url: string): { owner: string; repo: string; ref: string } | undefined { + const match = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/i.exec(url) + || /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/i.exec(url); + return match ? { owner: match[1], repo: match[2], ref: 'HEAD' } : undefined; + } + + protected override renderBody(container: HTMLElement): void { + super.renderBody(container); + + this.bodyContainer = dom.append(container, $('.file-tree-view-body')); + + // Welcome message for empty state + this.welcomeContainer = dom.append(this.bodyContainer, $('.file-tree-welcome')); + const welcomeIcon = dom.append(this.welcomeContainer, $('.file-tree-welcome-icon')); + welcomeIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.repoClone)); + const welcomeMessage = dom.append(this.welcomeContainer, $('.file-tree-welcome-message')); + welcomeMessage.textContent = localize('fileTreeView.noRepository', "No repository available for this session."); + + // Tree container + this.treeContainer = dom.append(this.bodyContainer, $('.file-tree-container.show-file-icons')); + this._register(createFileIconThemableTreeContainerScope(this.treeContainer, this.themeService)); + + this._register(this.onDidChangeBodyVisibility(visible => { + if (visible) { + this.onVisible(); + } else { + this.renderDisposables.clear(); + } + })); + + if (this.isBodyVisible()) { + this.onVisible(); + } + } + + private onVisible(): void { + this.renderDisposables.clear(); + this.logService.info('[FileTreeView] onVisible called'); + + // Create tree if needed + if (!this.tree && this.treeContainer) { + const resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this.onDidChangeBodyVisibility })); + const dataSource = new FileTreeDataSource(this.fileService, this.logService); + + this.tree = this.instantiationService.createInstance( + WorkbenchCompressibleAsyncDataTree, + 'FileTreeView', + this.treeContainer, + new FileTreeDelegate(), + new FileTreeCompressionDelegate(), + [this.instantiationService.createInstance(FileTreeRenderer, resourceLabels)], + dataSource, + { + accessibilityProvider: { + getAriaLabel: (element: IFileTreeItem) => element.name, + getWidgetAriaLabel: () => localize('fileTreeView', "File Tree") + }, + identityProvider: { + getId: (element: IFileTreeItem) => element.uri.toString() + }, + compressionEnabled: true, + collapseByDefault: (_e: IFileTreeItem) => true, + } + ); + } + + // Handle tree open events (open files in editor) + if (this.tree) { + this.renderDisposables.add(this.tree.onDidOpen(async (e) => { + if (!e.element || e.element.isDirectory) { + return; + } + + await this.editorService.openEditor({ + resource: e.element.uri, + options: e.editorOptions, + }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + })); + } + + // React to active session changes + let lastRootUri: URI | undefined; + this.renderDisposables.add(autorun(reader => { + const rootUri = this.treeRootUri.read(reader); + const hasRoot = !!rootUri; + + dom.setVisibility(hasRoot, this.treeContainer!); + dom.setVisibility(!hasRoot, this.welcomeContainer!); + + if (this.tree && rootUri && !isEqual(rootUri, lastRootUri)) { + lastRootUri = rootUri; + this.updateTitle(basename(rootUri.path) || rootUri.toString()); + this.treeInputDisposable.clear(); + this.tree.setInput(rootUri).then(() => { + this.layoutTree(); + }); + } else if (!rootUri && lastRootUri) { + lastRootUri = undefined; + } + })); + } + + private layoutTree(): void { + if (!this.tree) { + return; + } + this.tree.layout(this.currentBodyHeight, this.currentBodyWidth); + } + + protected override layoutBody(height: number, width: number): void { + super.layoutBody(height, width); + this.currentBodyHeight = height; + this.currentBodyWidth = width; + this.layoutTree(); + } + + override focus(): void { + super.focus(); + this.tree?.domFocus(); + } + + override dispose(): void { + this.tree?.dispose(); + this.tree = undefined; + super.dispose(); + } +} + +// --- View Pane Container + +export class FileTreeViewPaneContainer extends ViewPaneContainer { + constructor( + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @ITelemetryService telemetryService: ITelemetryService, + @IInstantiationService instantiationService: IInstantiationService, + @IContextMenuService contextMenuService: IContextMenuService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IConfigurationService configurationService: IConfigurationService, + @IExtensionService extensionService: IExtensionService, + @IWorkspaceContextService contextService: IWorkspaceContextService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @ILogService logService: ILogService, + ) { + super(FILE_TREE_VIEW_CONTAINER_ID, { mergeViewWithContainerWhenSingleView: true }, instantiationService, configurationService, layoutService, contextMenuService, telemetryService, extensionService, themeService, storageService, contextService, viewDescriptorService, logService); + } + + override create(parent: HTMLElement): void { + super.create(parent); + parent.classList.add('file-tree-viewlet'); + } +} diff --git a/src/vs/sessions/contrib/fileTreeView/browser/githubFileSystemProvider.ts b/src/vs/sessions/contrib/fileTreeView/browser/githubFileSystemProvider.ts new file mode 100644 index 0000000000000..289911e399578 --- /dev/null +++ b/src/vs/sessions/contrib/fileTreeView/browser/githubFileSystemProvider.ts @@ -0,0 +1,293 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileDeleteOptions, IFileOverwriteOptions, IFileSystemProviderWithFileReadWriteCapability, IFileWriteOptions, IStat, createFileSystemProviderError, IFileChange } from '../../../../platform/files/common/files.js'; +import { IRequestService, asJson } from '../../../../platform/request/common/request.js'; +import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; + +export const GITHUB_REMOTE_FILE_SCHEME = 'github-remote-file'; + +/** + * GitHub REST API response for the Trees endpoint. + * GET /repos/{owner}/{repo}/git/trees/{tree_sha}?recursive=1 + */ +interface IGitHubTreeResponse { + readonly sha: string; + readonly url: string; + readonly truncated: boolean; + readonly tree: readonly IGitHubTreeEntry[]; +} + +interface IGitHubTreeEntry { + readonly path: string; + readonly mode: string; + readonly type: 'blob' | 'tree'; + readonly sha: string; + readonly size?: number; + readonly url: string; +} + +interface ITreeCacheEntry { + /** Map from path → entry metadata */ + readonly entries: Map; + readonly fetchedAt: number; +} + +/** + * A readonly virtual filesystem provider backed by the GitHub REST API. + * + * URI format: github-remote-file://github/{owner}/{repo}/{ref}/{path...} + * + * For example: github-remote-file://github/microsoft/vscode/main/src/vs/base/common/uri.ts + * + * This provider fetches the full recursive tree from the GitHub Trees API on first + * access and caches it. Individual file contents are fetched on demand via the + * Blobs API. + */ +export class GitHubFileSystemProvider extends Disposable implements IFileSystemProviderWithFileReadWriteCapability { + + private readonly _onDidChangeCapabilities = this._register(new Emitter()); + readonly onDidChangeCapabilities: Event = this._onDidChangeCapabilities.event; + + readonly capabilities: FileSystemProviderCapabilities = + FileSystemProviderCapabilities.Readonly | + FileSystemProviderCapabilities.FileReadWrite | + FileSystemProviderCapabilities.PathCaseSensitive; + + private readonly _onDidChangeFile = this._register(new Emitter()); + readonly onDidChangeFile: Event = this._onDidChangeFile.event; + + /** Cache keyed by "owner/repo/ref" */ + private readonly treeCache = new Map(); + + /** Cache TTL - 5 minutes */ + private static readonly CACHE_TTL_MS = 5 * 60 * 1000; + + constructor( + @IRequestService private readonly requestService: IRequestService, + @IAuthenticationService private readonly authenticationService: IAuthenticationService, + @ILogService private readonly logService: ILogService, + ) { + super(); + } + + // --- URI parsing + + /** + * Parse a github-remote-file URI into its components. + * Format: github-remote-file://github/{owner}/{repo}/{ref}/{path...} + */ + private parseUri(resource: URI): { owner: string; repo: string; ref: string; path: string } { + // authority = "github" + // path = /{owner}/{repo}/{ref}/{rest...} + const parts = resource.path.split('/').filter(Boolean); + if (parts.length < 3) { + throw createFileSystemProviderError('Invalid github-remote-file URI: expected /{owner}/{repo}/{ref}/...', FileSystemProviderErrorCode.FileNotFound); + } + + const owner = parts[0]; + const repo = parts[1]; + const ref = parts[2]; + const path = parts.slice(3).join('/'); + + return { owner, repo, ref, path }; + } + + private getCacheKey(owner: string, repo: string, ref: string): string { + return `${owner}/${repo}/${ref}`; + } + + // --- GitHub API + + private async getAuthToken(): Promise { + const sessions = await this.authenticationService.getSessions('github', ['repo']); + if (sessions.length > 0) { + return sessions[0].accessToken; + } + + // Try to create a session if none exists + const session = await this.authenticationService.createSession('github', ['repo']); + return session.accessToken; + } + + private async fetchTree(owner: string, repo: string, ref: string): Promise { + const cacheKey = this.getCacheKey(owner, repo, ref); + const cached = this.treeCache.get(cacheKey); + if (cached && (Date.now() - cached.fetchedAt) < GitHubFileSystemProvider.CACHE_TTL_MS) { + return cached; + } + + this.logService.info(`[SessionRepoFS] Fetching tree for ${owner}/${repo}@${ref}`); + const token = await this.getAuthToken(); + + const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/trees/${encodeURIComponent(ref)}?recursive=1`; + const response = await this.requestService.request({ + type: 'GET', + url, + headers: { + 'Authorization': `token ${token}`, + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'VSCode-SessionRepoFS', + }, + }, CancellationToken.None); + + const data = await asJson(response); + if (!data) { + throw createFileSystemProviderError(`Failed to fetch tree for ${owner}/${repo}@${ref}`, FileSystemProviderErrorCode.Unavailable); + } + + const entries = new Map(); + + // Add root directory entry + entries.set('', { type: FileType.Directory, size: 0, sha: data.sha }); + + // Track directories implicitly from paths + const dirs = new Set(); + + for (const entry of data.tree) { + const fileType = entry.type === 'tree' ? FileType.Directory : FileType.File; + entries.set(entry.path, { type: fileType, size: entry.size ?? 0, sha: entry.sha }); + + if (fileType === FileType.Directory) { + dirs.add(entry.path); + } + + // Ensure parent directories are tracked + const pathParts = entry.path.split('/'); + for (let i = 1; i < pathParts.length; i++) { + const parentPath = pathParts.slice(0, i).join('/'); + if (!dirs.has(parentPath)) { + dirs.add(parentPath); + if (!entries.has(parentPath)) { + entries.set(parentPath, { type: FileType.Directory, size: 0, sha: '' }); + } + } + } + } + + const cacheEntry: ITreeCacheEntry = { entries, fetchedAt: Date.now() }; + this.treeCache.set(cacheKey, cacheEntry); + return cacheEntry; + } + + // --- IFileSystemProvider + + async stat(resource: URI): Promise { + const { owner, repo, ref, path } = this.parseUri(resource); + const tree = await this.fetchTree(owner, repo, ref); + const entry = tree.entries.get(path); + + if (!entry) { + throw createFileSystemProviderError('File not found', FileSystemProviderErrorCode.FileNotFound); + } + + return { + type: entry.type, + ctime: 0, + mtime: 0, + size: entry.size, + }; + } + + async readdir(resource: URI): Promise<[string, FileType][]> { + const { owner, repo, ref, path } = this.parseUri(resource); + const tree = await this.fetchTree(owner, repo, ref); + + const prefix = path ? path + '/' : ''; + const result: [string, FileType][] = []; + + for (const [entryPath, entry] of tree.entries) { + if (!entryPath.startsWith(prefix)) { + continue; + } + + const relativePath = entryPath.slice(prefix.length); + // Only include direct children (no nested paths) + if (relativePath && !relativePath.includes('/')) { + result.push([relativePath, entry.type]); + } + } + + return result; + } + + async readFile(resource: URI): Promise { + const { owner, repo, ref, path } = this.parseUri(resource); + const tree = await this.fetchTree(owner, repo, ref); + const entry = tree.entries.get(path); + + if (!entry || entry.type === FileType.Directory) { + throw createFileSystemProviderError('File not found', FileSystemProviderErrorCode.FileNotFound); + } + + const token = await this.getAuthToken(); + + // Fetch file content via the Blobs API + const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/blobs/${encodeURIComponent(entry.sha)}`; + const response = await this.requestService.request({ + type: 'GET', + url, + headers: { + 'Authorization': `token ${token}`, + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'VSCode-SessionRepoFS', + }, + }, CancellationToken.None); + + const data = await asJson<{ content: string; encoding: string }>(response); + if (!data) { + throw createFileSystemProviderError(`Failed to read file ${path}`, FileSystemProviderErrorCode.Unavailable); + } + + if (data.encoding === 'base64') { + const binaryString = atob(data.content.replace(/\n/g, '')); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; + } + + return new TextEncoder().encode(data.content); + } + + // --- Readonly stubs + + watch(): IDisposable { + return Disposable.None; + } + + async writeFile(_resource: URI, _content: Uint8Array, _opts: IFileWriteOptions): Promise { + throw createFileSystemProviderError('Operation not supported', FileSystemProviderErrorCode.NoPermissions); + } + + async mkdir(_resource: URI): Promise { + throw createFileSystemProviderError('Operation not supported', FileSystemProviderErrorCode.NoPermissions); + } + + async delete(_resource: URI, _opts: IFileDeleteOptions): Promise { + throw createFileSystemProviderError('Operation not supported', FileSystemProviderErrorCode.NoPermissions); + } + + async rename(_from: URI, _to: URI, _opts: IFileOverwriteOptions): Promise { + throw createFileSystemProviderError('Operation not supported', FileSystemProviderErrorCode.NoPermissions); + } + + // --- Cache management + + invalidateCache(owner: string, repo: string, ref: string): void { + this.treeCache.delete(this.getCacheKey(owner, repo, ref)); + } + + override dispose(): void { + this.treeCache.clear(); + super.dispose(); + } +} diff --git a/src/vs/sessions/contrib/fileTreeView/browser/media/fileTreeView.css b/src/vs/sessions/contrib/fileTreeView/browser/media/fileTreeView.css new file mode 100644 index 0000000000000..3affb3068f9ff --- /dev/null +++ b/src/vs/sessions/contrib/fileTreeView/browser/media/fileTreeView.css @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.file-tree-view-body { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.file-tree-view-body .file-tree-welcome { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 16px; + gap: 8px; + color: var(--vscode-descriptionForeground); +} + +.file-tree-view-body .file-tree-welcome-icon { + font-size: 24px; + opacity: 0.6; +} + +.file-tree-view-body .file-tree-welcome-message { + font-size: 12px; + text-align: center; +} + +.file-tree-view-body .file-tree-container { + flex: 1; + overflow: hidden; +} diff --git a/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css index d671775dbd57c..8a4298cf90953 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css +++ b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css @@ -124,10 +124,11 @@ .ai-customization-toolbar .ai-customization-toolbar-content { max-height: 500px; overflow: hidden; - transition: max-height 0.2s ease-out; + transition: max-height 0.2s ease-out, display 0s linear 0.2s; } .ai-customization-toolbar.collapsed .ai-customization-toolbar-content { max-height: 0; + display: none; } } diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index 81e6f1b534a56..14ea745fd17be 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -196,6 +196,7 @@ import './contrib/chat/browser/chat.contribution.js'; import './contrib/sessions/browser/sessions.contribution.js'; import './contrib/sessions/browser/customizationsToolbar.contribution.js'; import './contrib/changesView/browser/changesView.contribution.js'; +import './contrib/fileTreeView/browser/fileTreeView.contribution.js'; // view registration disabled; filesystem provider still needed import './contrib/configuration/browser/configuration.contribution.js'; //#endregion diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts index a442be07b038b..14c73c7b688ea 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts @@ -27,6 +27,7 @@ import { ChatModeKind } from '../../common/constants.js'; import { IChatAccessibilityService, IChatWidgetService } from '../chat.js'; import { CHAT_CATEGORY } from './chatActions.js'; +export const MarkHelpfulActionId = 'workbench.action.chat.markHelpful'; export const MarkUnhelpfulActionId = 'workbench.action.chat.markUnhelpful'; const enableFeedbackConfig = 'config.telemetry.feedback.enabled'; diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgetRegistry.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgetRegistry.ts new file mode 100644 index 0000000000000..9cf993aa7bb07 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgetRegistry.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as event from '../../../../../base/common/event.js'; +import { IDisposable } from '../../../../../base/common/lifecycle.js'; +import { createDecorator, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js'; + +/** + * Interface for a contributed attachment widget instance. + */ +export interface IChatAttachmentWidgetInstance extends IDisposable { + readonly element: HTMLElement; + readonly onDidDelete: event.Event; + readonly onDidOpen: event.Event; +} + +/** + * Factory function type for creating attachment widgets. + * Receives the instantiation service so it can create DI-injected widget instances. + */ +export type ChatAttachmentWidgetFactory = ( + instantiationService: IInstantiationService, + attachment: IChatRequestVariableEntry, + options: { shouldFocusClearButton: boolean; supportsDeletion: boolean }, + container: HTMLElement, +) => IChatAttachmentWidgetInstance; + +export const IChatAttachmentWidgetRegistry = createDecorator('chatAttachmentWidgetRegistry'); + +export interface IChatAttachmentWidgetRegistry { + readonly _serviceBrand: undefined; + + /** + * Register a widget factory for a specific attachment kind. + */ + registerFactory(kind: string, factory: ChatAttachmentWidgetFactory): IDisposable; + + /** + * Try to create a widget for the given attachment using a registered factory. + * Returns undefined if no factory is registered for the attachment's kind. + */ + createWidget( + instantiationService: IInstantiationService, + attachment: IChatRequestVariableEntry, + options: { shouldFocusClearButton: boolean; supportsDeletion: boolean }, + container: HTMLElement, + ): IChatAttachmentWidgetInstance | undefined; +} + +export class ChatAttachmentWidgetRegistry implements IChatAttachmentWidgetRegistry { + + declare readonly _serviceBrand: undefined; + + private readonly _factories = new Map(); + + registerFactory(kind: string, factory: ChatAttachmentWidgetFactory): IDisposable { + this._factories.set(kind, factory); + return { + dispose: () => { + if (this._factories.get(kind) === factory) { + this._factories.delete(kind); + } + } + }; + } + + createWidget( + instantiationService: IInstantiationService, + attachment: IChatRequestVariableEntry, + options: { shouldFocusClearButton: boolean; supportsDeletion: boolean }, + container: HTMLElement, + ): IChatAttachmentWidgetInstance | undefined { + const factory = this._factories.get(attachment.kind); + if (!factory) { + return undefined; + } + return factory(instantiationService, attachment, options, container); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index e882231c7e131..9c86e875e9b01 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -51,7 +51,7 @@ import { ILanguageModelToolsConfirmationService } from '../common/tools/language import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; import { ChatPromptFilesExtensionPointHandler } from '../common/promptSyntax/chatPromptFilesContribution.js'; import { PromptsConfig } from '../common/promptSyntax/config/config.js'; -import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION, DEFAULT_SKILL_SOURCE_FOLDERS, AGENTS_SOURCE_FOLDER, AGENT_FILE_EXTENSION, SKILL_FILENAME, CLAUDE_AGENTS_SOURCE_FOLDER, CLAUDE_RULES_SOURCE_FOLDER, DEFAULT_HOOK_FILE_PATHS } from '../common/promptSyntax/config/promptFileLocations.js'; +import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION, DEFAULT_SKILL_SOURCE_FOLDERS, AGENTS_SOURCE_FOLDER, AGENT_FILE_EXTENSION, SKILL_FILENAME, CLAUDE_AGENTS_SOURCE_FOLDER, DEFAULT_HOOK_FILE_PATHS, DEFAULT_INSTRUCTIONS_SOURCE_FOLDERS } from '../common/promptSyntax/config/promptFileLocations.js'; import { PromptLanguageFeaturesProvider } from '../common/promptSyntax/promptFileContributions.js'; import { AGENT_DOCUMENTATION_URL, INSTRUCTIONS_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL, SKILL_DOCUMENTATION_URL, HOOK_DOCUMENTATION_URL } from '../common/promptSyntax/promptTypes.js'; import { hookFileSchema, HOOK_SCHEMA_URI, HOOK_FILE_GLOB } from '../common/promptSyntax/hookSchema.js'; @@ -96,6 +96,7 @@ import { ChatAccessibilityService } from './accessibility/chatAccessibilityServi import './attachments/chatAttachmentModel.js'; import './widget/input/chatStatusWidget.js'; import { ChatAttachmentResolveService, IChatAttachmentResolveService } from './attachments/chatAttachmentResolveService.js'; +import { ChatAttachmentWidgetRegistry, IChatAttachmentWidgetRegistry } from './attachments/chatAttachmentWidgetRegistry.js'; import { ChatMarkdownAnchorService, IChatMarkdownAnchorService } from './widget/chatContentParts/chatMarkdownAnchorService.js'; import { ChatContextPickService, IChatContextPickService } from './attachments/chatContextPickService.js'; import { ChatInputBoxContentProvider } from './widget/input/editor/chatEditorInputContentProvider.js'; @@ -288,7 +289,19 @@ configurationRegistry.registerConfiguration({ mode: 'auto' } }, - + 'chat.upvoteAnimation': { + type: 'string', + enum: ['off', 'confetti', 'floatingThumbs', 'pulseWave', 'radiantLines'], + enumDescriptions: [ + nls.localize('chat.upvoteAnimation.off', "No animation is shown."), + nls.localize('chat.upvoteAnimation.confetti', "Shows a confetti burst animation around the thumbs up button."), + nls.localize('chat.upvoteAnimation.floatingThumbs', "Shows floating thumbs up icons rising from the button."), + nls.localize('chat.upvoteAnimation.pulseWave', "Shows expanding pulse rings from the button."), + nls.localize('chat.upvoteAnimation.radiantLines', "Shows radiant lines emanating from the button."), + ], + description: nls.localize('chat.upvoteAnimation', "Controls whether an animation is shown when clicking the thumbs up button on a chat response."), + default: 'floatingThumbs', + }, 'chat.experimental.detectParticipant.enabled': { type: 'boolean', deprecationMessage: nls.localize('chat.experimental.detectParticipant.enabled.deprecated', "This setting is deprecated. Please use `chat.detectParticipant.enabled` instead."), @@ -735,8 +748,7 @@ configurationRegistry.registerConfiguration({ INSTRUCTIONS_DOCUMENTATION_URL, ), default: { - [INSTRUCTIONS_DEFAULT_SOURCE_FOLDER]: true, - [CLAUDE_RULES_SOURCE_FOLDER]: true, + ...DEFAULT_INSTRUCTIONS_SOURCE_FOLDERS.map((folder) => ({ [folder.path]: true })).reduce((acc, curr) => ({ ...acc, ...curr }), {}), }, additionalProperties: { type: 'boolean' }, propertyNames: { @@ -747,8 +759,7 @@ configurationRegistry.registerConfiguration({ tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'], examples: [ { - [INSTRUCTIONS_DEFAULT_SOURCE_FOLDER]: true, - [CLAUDE_RULES_SOURCE_FOLDER]: true, + [DEFAULT_INSTRUCTIONS_SOURCE_FOLDERS[0].path]: true, }, { [INSTRUCTIONS_DEFAULT_SOURCE_FOLDER]: true, @@ -1542,6 +1553,7 @@ registerSingleton(IPromptsService, PromptsService, InstantiationType.Delayed); registerSingleton(IChatContextPickService, ChatContextPickService, InstantiationType.Delayed); registerSingleton(IChatModeService, ChatModeService, InstantiationType.Delayed); registerSingleton(IChatAttachmentResolveService, ChatAttachmentResolveService, InstantiationType.Delayed); +registerSingleton(IChatAttachmentWidgetRegistry, ChatAttachmentWidgetRegistry, InstantiationType.Delayed); registerSingleton(IChatTodoListService, ChatTodoListService, InstantiationType.Delayed); registerSingleton(IChatOutputRendererService, ChatOutputRendererService, InstantiationType.Delayed); registerSingleton(IChatLayoutService, ChatLayoutService, InstantiationType.Delayed); 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 aab01ee1d492a..ada489b06acfe 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -576,8 +576,16 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ if (chatOptions) { const resource = URI.revive(chatOptions.resource); const ref = await chatService.loadSessionForResource(resource, ChatAgentLocation.Chat, CancellationToken.None); - await chatService.sendRequest(resource, chatOptions.prompt, { agentIdSilent: type, attachedContext: chatOptions.attachedContext }); - ref?.dispose(); + try { + const result = await chatService.sendRequest(resource, chatOptions.prompt, { agentIdSilent: type, attachedContext: chatOptions.attachedContext }); + if (result.kind === 'queued') { + await result.deferred; + } else if (result.kind === 'sent') { + await result.data.responseCompletePromise; + } + } finally { + ref?.dispose(); + } } } }), diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts index bb23081defd6c..50a3495c4f845 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts @@ -267,6 +267,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements label: localize('allowSession', 'Allow in this Session'), detail: localize('allowSessionTooltip', 'Allow this tool to run in this session without confirmation.'), divider: !!actions.length, + scope: 'session', select: async () => { this._preExecutionToolConfirmStore.setAutoConfirmation(ref.toolId, 'session'); return true; @@ -275,6 +276,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements { label: localize('allowWorkspace', 'Allow in this Workspace'), detail: localize('allowWorkspaceTooltip', 'Allow this tool to run in this workspace without confirmation.'), + scope: 'workspace', select: async () => { this._preExecutionToolConfirmStore.setAutoConfirmation(ref.toolId, 'workspace'); return true; @@ -283,6 +285,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements { label: localize('allowGlobally', 'Always Allow'), detail: localize('allowGloballyTooltip', 'Always allow this tool to run without confirmation.'), + scope: 'profile', select: async () => { this._preExecutionToolConfirmStore.setAutoConfirmation(ref.toolId, 'profile'); return true; @@ -298,6 +301,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements label: localize('allowServerSession', 'Allow Tools from {0} in this Session', serverLabel), detail: localize('allowServerSessionTooltip', 'Allow all tools from this server to run in this session without confirmation.'), divider: true, + scope: 'session', select: async () => { this._preExecutionServerConfirmStore.setAutoConfirmation(definitionId, 'session'); return true; @@ -306,6 +310,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements { label: localize('allowServerWorkspace', 'Allow Tools from {0} in this Workspace', serverLabel), detail: localize('allowServerWorkspaceTooltip', 'Allow all tools from this server to run in this workspace without confirmation.'), + scope: 'workspace', select: async () => { this._preExecutionServerConfirmStore.setAutoConfirmation(definitionId, 'workspace'); return true; @@ -314,6 +319,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements { label: localize('allowServerGlobally', 'Always Allow Tools from {0}', serverLabel), detail: localize('allowServerGloballyTooltip', 'Always allow all tools from this server to run without confirmation.'), + scope: 'profile', select: async () => { this._preExecutionServerConfirmStore.setAutoConfirmation(definitionId, 'profile'); return true; @@ -345,6 +351,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements label: localize('allowSessionPost', 'Allow Without Review in this Session'), detail: localize('allowSessionPostTooltip', 'Allow results from this tool to be sent without confirmation in this session.'), divider: !!actions.length, + scope: 'session', select: async () => { this._postExecutionToolConfirmStore.setAutoConfirmation(ref.toolId, 'session'); return true; @@ -353,6 +360,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements { label: localize('allowWorkspacePost', 'Allow Without Review in this Workspace'), detail: localize('allowWorkspacePostTooltip', 'Allow results from this tool to be sent without confirmation in this workspace.'), + scope: 'workspace', select: async () => { this._postExecutionToolConfirmStore.setAutoConfirmation(ref.toolId, 'workspace'); return true; @@ -361,6 +369,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements { label: localize('allowGloballyPost', 'Always Allow Without Review'), detail: localize('allowGloballyPostTooltip', 'Always allow results from this tool to be sent without confirmation.'), + scope: 'profile', select: async () => { this._postExecutionToolConfirmStore.setAutoConfirmation(ref.toolId, 'profile'); return true; @@ -376,6 +385,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements label: localize('allowServerSessionPost', 'Allow Tools from {0} Without Review in this Session', serverLabel), detail: localize('allowServerSessionPostTooltip', 'Allow results from all tools from this server to be sent without confirmation in this session.'), divider: true, + scope: 'session', select: async () => { this._postExecutionServerConfirmStore.setAutoConfirmation(definitionId, 'session'); return true; @@ -384,6 +394,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements { label: localize('allowServerWorkspacePost', 'Allow Tools from {0} Without Review in this Workspace', serverLabel), detail: localize('allowServerWorkspacePostTooltip', 'Allow results from all tools from this server to be sent without confirmation in this workspace.'), + scope: 'workspace', select: async () => { this._postExecutionServerConfirmStore.setAutoConfirmation(definitionId, 'workspace'); return true; @@ -392,6 +403,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements { label: localize('allowServerGloballyPost', 'Always Allow Tools from {0} Without Review', serverLabel), detail: localize('allowServerGloballyPostTooltip', 'Always allow results from all tools from this server to be sent without confirmation.'), + scope: 'profile', select: async () => { this._postExecutionServerConfirmStore.setAutoConfirmation(definitionId, 'profile'); return true; diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 1f676140061fc..020f07ec8a215 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -1438,7 +1438,15 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo add(alias, fullReferenceName); } if (tool.legacyToolReferenceFullNames) { + // If the tool is in a toolset (fullReferenceName has a '/'), also add the + // namespaced form of legacy names (e.g. 'vscode/oldName' → 'vscode/newName') + const slashIndex = fullReferenceName.lastIndexOf('/'); + const toolSetPrefix = slashIndex !== -1 ? fullReferenceName.substring(0, slashIndex + 1) : undefined; + for (const legacyName of tool.legacyToolReferenceFullNames) { + if (toolSetPrefix && !legacyName.includes('/')) { + add(toolSetPrefix + legacyName, fullReferenceName); + } // for any 'orphaned' toolsets (toolsets that no longer exist and // do not have an explicit legacy mapping), we should // just point them to the list of tools directly diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatConfetti.ts b/src/vs/workbench/contrib/chat/browser/widget/chatConfetti.ts deleted file mode 100644 index 83a8d6be039dc..0000000000000 --- a/src/vs/workbench/contrib/chat/browser/widget/chatConfetti.ts +++ /dev/null @@ -1,85 +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 * as dom from '../../../../../base/browser/dom.js'; - -const confettiColors = [ - '#f44336', '#e91e63', '#9c27b0', '#673ab7', - '#3f51b5', '#2196f3', '#03a9f4', '#00bcd4', - '#009688', '#4caf50', '#8bc34a', '#ffeb3b', - '#ffc107', '#ff9800', '#ff5722' -]; - -let activeOverlay: HTMLElement | undefined; - -/** - * Triggers a confetti animation inside the given container element. - */ -export function triggerConfetti(container: HTMLElement) { - if (activeOverlay) { - return; - } - - const overlay = dom.$('.chat-confetti-overlay'); - overlay.style.position = 'absolute'; - overlay.style.inset = '0'; - overlay.style.pointerEvents = 'none'; - overlay.style.overflow = 'hidden'; - overlay.style.zIndex = '1000'; - container.appendChild(overlay); - activeOverlay = overlay; - - const { width, height } = container.getBoundingClientRect(); - for (let i = 0; i < 250; i++) { - const part = dom.$('.chat-confetti-particle'); - part.style.position = 'absolute'; - part.style.width = `${Math.random() * 8 + 4}px`; - part.style.height = `${Math.random() * 8 + 4}px`; - part.style.backgroundColor = confettiColors[Math.floor(Math.random() * confettiColors.length)]; - part.style.borderRadius = Math.random() > 0.5 ? '50%' : '0'; - part.style.left = `${Math.random() * width}px`; - part.style.top = '-10px'; - part.style.opacity = '1'; - - overlay.appendChild(part); - - const targetX = (Math.random() - 0.5) * width * 0.8; - const targetY = Math.random() * height * 0.8 + height * 0.1; - const rotation = Math.random() * 720 - 360; - const duration = Math.random() * 1000 + 1500; - const delay = Math.random() * 400; - - part.animate([ - { - transform: 'translate(0, 0) rotate(0deg)', - opacity: 1 - }, - { - transform: `translate(${targetX * 0.5}px, ${targetY * 0.5}px) rotate(${rotation * 0.5}deg)`, - opacity: 1, - offset: 0.3 - }, - { - transform: `translate(${targetX}px, ${targetY}px) rotate(${rotation}deg)`, - opacity: 1, - offset: 0.75 - }, - { - transform: `translate(${targetX * 1.1}px, ${targetY + 40}px) rotate(${rotation + 30}deg)`, - opacity: 0 - } - ], { - duration, - delay, - easing: 'linear', - fill: 'forwards' - }); - } - - setTimeout(() => { - overlay.remove(); - activeOverlay = undefined; - }, 3000); -} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css index 553704389064c..3a2d4c3cef96b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css @@ -233,6 +233,10 @@ .rendered-markdown > p { margin: 0; + + & + p { + margin-top: 5px; + } } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts index b54e72a3215fa..82b983d3483aa 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts @@ -27,7 +27,11 @@ export interface IToolConfirmationConfig { subtitle?: string; } -type AbstractToolPrimaryAction = IChatConfirmationButton<(() => void)> | Separator; +interface IAbstractToolPrimaryAction extends IChatConfirmationButton<(() => void)> { + scope?: 'session' | 'workspace' | 'profile'; +} + +type AbstractToolPrimaryAction = IAbstractToolPrimaryAction | Separator; /** * Base class for a tool confirmation. @@ -75,14 +79,32 @@ export abstract class AbstractToolConfirmationSubPart extends BaseChatToolInvoca const skipTooltip = keybindingService.appendKeybinding(config.skipLabel, config.skipActionId); const additionalActions = this.additionalPrimaryActions(); + + // find session scoped action + const sessionAction = additionalActions.find( + (action): action is IAbstractToolPrimaryAction => 'scope' in action && action.scope === 'session' + ); + + // regular allow action + const allowAction: IAbstractToolPrimaryAction = { + label: config.allowLabel, + tooltip: allowTooltip, + data: () => { this.confirmWith(toolInvocation, { type: ToolConfirmKind.UserAction }); }, + }; + + const primaryAction = sessionAction ?? allowAction; + + // rebuild additional list with allow action + const moreActions = sessionAction + ? [allowAction, ...additionalActions.filter(a => a !== sessionAction)] + : additionalActions; + buttons = [ { - label: config.allowLabel, - tooltip: allowTooltip, - data: () => { - this.confirmWith(toolInvocation, { type: ToolConfirmKind.UserAction }); - }, - moreActions: additionalActions.length > 0 ? additionalActions : undefined, + label: primaryAction.label, + tooltip: primaryAction.tooltip, + data: primaryAction.data, + moreActions: moreActions.length > 0 ? moreActions : undefined, }, { label: localize('skip', "Skip"), diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts index f30c20e399b1f..2386b799b6c20 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts @@ -392,6 +392,7 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS const tooltip = this.keybindingService.appendKeybinding(tooltipDetail, actionId); return { label, tooltip }; }; + return [ { ...getLabelAndTooltip(localize('tool.allow', "Allow"), AcceptToolConfirmationActionId), diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts index ffecd3be04596..9ebf247ad9432 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts @@ -104,6 +104,7 @@ export class ToolConfirmationSubPart extends AbstractToolConfirmationSubPart { actions.push({ label: action.label, tooltip: action.detail, + scope: action.scope, data: async () => { const shouldConfirm = await action.select(); if (shouldConfirm) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPostExecuteConfirmationPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPostExecuteConfirmationPart.ts index dfe354e2514dc..4805392c6db60 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPostExecuteConfirmationPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPostExecuteConfirmationPart.ts @@ -87,6 +87,7 @@ export class ChatToolPostExecuteConfirmationPart extends AbstractToolConfirmatio actions.push({ label: action.label, tooltip: action.detail, + scope: action.scope, data: async () => { const shouldConfirm = await action.select(); if (shouldConfirm) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 3b22171302e8d..62dc18fafa583 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -32,7 +32,7 @@ import { clamp } from '../../../../../base/common/numbers.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; -import { IMenuEntryActionViewItemOptions, createActionViewItem } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { IMenuEntryActionViewItemOptions, MenuEntryActionViewItem, createActionViewItem } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; import { MenuId, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; @@ -65,7 +65,8 @@ import { IChatChangesSummaryPart, IChatCodeCitations, IChatErrorDetailsPart, ICh import { getNWords } from '../../common/model/chatWordCounter.js'; import { CodeBlockModelCollection } from '../../common/widget/codeBlockModelCollection.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, CollapsedToolsDisplayMode, ThinkingDisplayMode } from '../../common/constants.js'; -import { MarkUnhelpfulActionId } from '../actions/chatTitleActions.js'; +import { ClickAnimation } from '../../../../../base/browser/ui/animations/animations.js'; +import { MarkHelpfulActionId, MarkUnhelpfulActionId } from '../actions/chatTitleActions.js'; import { ChatTreeItem, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions, IChatWidgetService } from '../chat.js'; import { ChatAgentHover, getChatAgentHoverOptions } from './chatAgentHover.js'; import { ChatContentMarkdownRenderer } from './chatContentMarkdownRenderer.js'; @@ -169,6 +170,16 @@ export interface IChatRendererDelegate { const mostRecentResponseClassName = 'chat-most-recent-response'; +function upvoteAnimationSettingToEnum(value: string | undefined): ClickAnimation | undefined { + switch (value) { + case 'confetti': return ClickAnimation.Confetti; + case 'floatingThumbs': return ClickAnimation.FloatingIcons; + case 'pulseWave': return ClickAnimation.PulseWave; + case 'radiantLines': return ClickAnimation.RadiantLines; + default: return undefined; + } +} + export class ChatListItemRenderer extends Disposable implements ITreeRenderer { static readonly ID = 'item'; @@ -504,6 +515,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('chat.upvoteAnimation')); + return scopedInstantiationService.createInstance(MenuEntryActionViewItem, action, { ...options, onClickAnimation: animation }); + } return createActionViewItem(scopedInstantiationService, action, options); } })); @@ -1201,6 +1216,15 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer 0) { + items.push({ kind: ActionListItemKind.Separator }); + } for (const item of promotedItems) { if (item.kind === 'available') { items.push(createModelItem(createModelAction(item.model, selectedModelId, onSelect), item.model)); @@ -276,7 +278,9 @@ export function buildModelPickerItems( }); if (otherModels.length > 0) { - items.push({ kind: ActionListItemKind.Separator }); + if (items.length > 0) { + items.push({ kind: ActionListItemKind.Separator }); + } items.push({ item: { id: 'otherModels', 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 477895be331c5..ce838a3e0a146 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 @@ -410,7 +410,8 @@ class ChatTokenDeleter extends Disposable { // If this was a simple delete, try to find out whether it was inside a token if (!change.text && this.widget.viewModel) { - const previousParsedValue = parser.parseChatRequest(this.widget.viewModel.sessionResource, previousInputValue, widget.location, { selectedAgent: previousSelectedAgent, mode: this.widget.input.currentModeKind }); + const attachmentCapabilities = previousSelectedAgent?.capabilities ?? this.widget.attachmentCapabilities; + const previousParsedValue = parser.parseChatRequest(this.widget.viewModel.sessionResource, previousInputValue, widget.location, { selectedAgent: previousSelectedAgent, mode: this.widget.input.currentModeKind, attachmentCapabilities }); // For dynamic variables, this has to happen in ChatDynamicVariableModel with the other bookkeeping const deletableTokens = previousParsedValue.parts.filter(p => p instanceof ChatRequestAgentPart || p instanceof ChatRequestAgentSubcommandPart || p instanceof ChatRequestSlashCommandPart || p instanceof ChatRequestSlashPromptPart || p instanceof ChatRequestToolPart); diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 9ff7e671ca371..4f9bbd680332a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -329,8 +329,7 @@ border-right: none; } -.interactive-item-container .value .rendered-markdown table tr:last-child td, -.interactive-item-container .value .rendered-markdown table tr:last-child th { +.interactive-item-container .value .rendered-markdown table tbody tr:last-child td { border-bottom: none; } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index eba0df56ba90c..e2b5b29c409fe 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -869,7 +869,7 @@ export class ChatService extends Disposable implements IChatService { private _sendRequestAsync(model: ChatModel, sessionResource: URI, parsedRequest: IParsedChatRequest, attempt: number, enableCommandDetection: boolean, defaultAgent: IChatAgentData, location: ChatAgentLocation, options?: IChatSendRequestOptions): IChatSendRequestResponseState { const followupsCancelToken = this.refreshFollowupsCancellationToken(sessionResource); - let request: ChatRequestModel; + let request: ChatRequestModel | undefined; const agentPart = parsedRequest.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart); const agentSlashCommandPart = parsedRequest.parts.find((r): r is ChatRequestAgentSubcommandPart => r instanceof ChatRequestAgentSubcommandPart); const commandPart = parsedRequest.parts.find((r): r is ChatRequestSlashCommandPart => r instanceof ChatRequestSlashCommandPart); @@ -917,7 +917,9 @@ export class ChatService extends Disposable implements IChatService { this.trace('sendRequest', `Provider returned progress: ${JSON.stringify(progressItem)}`); } - model.acceptResponseProgress(request, progressItem, !isLast); + if (request) { + model.acceptResponseProgress(request, progressItem, !isLast); + } } completeResponseCreated(); }; @@ -1017,7 +1019,7 @@ export class ChatService extends Disposable implements IChatService { return; } - if (tools) { + if (tools && request) { this.chatAgentService.setRequestTools(agent.id, request.id, tools); // in case the request has not been sent out yet: agentRequest.userSelectedTools = tools; @@ -1048,7 +1050,7 @@ export class ChatService extends Disposable implements IChatService { const result = await this.chatAgentService.detectAgentOrCommand(chatAgentRequest, defaultAgentHistory, { location }, token); if (result && this.chatAgentService.getAgent(result.agent.id)?.locations?.includes(location)) { // Update the response in the ChatModel to reflect the detected agent and command - request.response?.setAgent(result.agent, result.command); + request?.response?.setAgent(result.agent, result.command); detectedAgent = result.agent; detectedCommand = result.command; } @@ -1066,7 +1068,7 @@ export class ChatService extends Disposable implements IChatService { const pendingRequest = this._pendingRequests.get(sessionResource); if (pendingRequest) { store.add(autorun(reader => { - if (pendingRequest.yieldRequested.read(reader)) { + if (pendingRequest.yieldRequested.read(reader) && request) { this.chatAgentService.setYieldRequested(agent.id, request.id); } })); @@ -1121,7 +1123,9 @@ export class ChatService extends Disposable implements IChatService { throw new Error(`Cannot handle request`); } - if (token.isCancellationRequested && !rawResult) { + if ((token.isCancellationRequested && !rawResult)) { + return; + } else if (!request) { return; } else { if (!rawResult) { @@ -1155,7 +1159,7 @@ export class ChatService extends Disposable implements IChatService { request.response?.complete(); if (agentOrCommandFollowups) { agentOrCommandFollowups.then(followups => { - model.setFollowups(request, followups); + model.setFollowups(request!, followups); const commandForTelemetry = agentSlashCommandPart ? agentSlashCommandPart.command.name : commandPart?.slashCommand.command; this._chatServiceTelemetry.retrievedFollowups(agentPart?.agent.id ?? '', commandForTelemetry, followups?.length ?? 0); }); @@ -1163,15 +1167,15 @@ export class ChatService extends Disposable implements IChatService { } } catch (err) { this.logService.error(`Error while handling chat request: ${toErrorMessage(err, true)}`); - requestTelemetry.complete({ - timeToFirstProgress: undefined, - totalTime: undefined, - result: 'error', - requestType, - detectedAgent, - request, - }); if (request) { + requestTelemetry.complete({ + timeToFirstProgress: undefined, + totalTime: undefined, + result: 'error', + requestType, + detectedAgent, + request, + }); const rawResult: IChatAgentResult = { errorDetails: { message: err.message } }; model.setResponse(request, rawResult); completeResponseCreated(); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts index db61519b2b852..ed04e3aa641e2 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts @@ -167,6 +167,7 @@ export const DEFAULT_SKILL_SOURCE_FOLDERS: readonly IPromptSourceFolder[] = [ export const DEFAULT_INSTRUCTIONS_SOURCE_FOLDERS: readonly IPromptSourceFolder[] = [ { path: INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, source: PromptFileSource.GitHubWorkspace, storage: PromptsStorage.local }, { path: CLAUDE_RULES_SOURCE_FOLDER, source: PromptFileSource.ClaudeWorkspace, storage: PromptsStorage.local }, + { path: '~/.copilot/instructions', source: PromptFileSource.CopilotPersonal, storage: PromptsStorage.user }, { path: '~/' + CLAUDE_RULES_SOURCE_FOLDER, source: PromptFileSource.ClaudePersonal, storage: PromptsStorage.user }, ]; diff --git a/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts b/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts index 9994bcffbcb8a..9ec0508a489f2 100644 --- a/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts +++ b/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts @@ -218,7 +218,8 @@ export class ChatRequestParser { } } - if (!usedAgent || context?.attachmentCapabilities?.supportsPromptAttachments) { + const capabilities = context?.attachmentCapabilities ?? usedAgent?.capabilities ?? context?.attachmentCapabilities; + if (!usedAgent || capabilities?.supportsPromptAttachments) { const slashCommands = this.slashCommandService.getCommands(location, context?.mode ?? ChatModeKind.Ask); const slashCommand = slashCommands.find(c => c.command === command); if (slashCommand) { diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts index 1655b0dac454e..8c503de71cf21 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts @@ -25,6 +25,34 @@ import { URI } from '../../../../../../base/common/uri.js'; // Use a distinct id to avoid clashing with extension-provided tools export const AskQuestionsToolId = 'vscode_askQuestions'; +// Soft limits are used in the schema to guide the model +// Hard limits are more lenient and used to truncate if the model overshoots +// +// Example text at each limit: +// - header soft (50 chars): "Which database engine do you want to use for this?" +// - header hard (75 chars): "Which database engine and connection pooling strategy do you want to use here?" +// - question soft (200 chars): "What testing framework would you like to use for this project? Consider factors like your team's familiarity, community support, and integration with your existing CI/CD pipeline when making a choice." +// - question hard (300 chars): "What testing framework would you like to use for this project? Consider factors like your team's familiarity with the framework, community support and documentation quality, integration with your existing CI/CD pipeline, and the specific testing needs of your application architecture when deciding." +const SoftLimits = { + header: 50, + question: 200 +} as const; + +const HardLimits = { + header: 75, + question: 300 +} as const; + +function truncateToLimit(value: string | undefined, limit: number): string | undefined { + if (value === undefined) { + return undefined; + } + if (value.length > limit) { + return value.slice(0, limit - 3) + '...'; + } + return value; +} + export interface IQuestionOption { readonly label: string; readonly description?: string; @@ -60,12 +88,12 @@ export function createAskQuestionsToolData(): IToolData { header: { type: 'string', description: 'Short identifier for the question. Must be unique so answers can be mapped back to the question.', - maxLength: 50 + maxLength: SoftLimits.header }, question: { type: 'string', description: 'The question text to display to the user. Keep it concise, ideally one sentence.', - maxLength: 200 + maxLength: SoftLimits.question }, multiSelect: { type: 'boolean', @@ -83,13 +111,11 @@ export function createAskQuestionsToolData(): IToolData { properties: { label: { type: 'string', - description: 'Display label and value for the option.', - maxLength: 100 + description: 'Display label and value for the option.' }, description: { type: 'string', - description: 'Optional secondary text shown with the option.', - maxLength: 200 + description: 'Optional secondary text shown with the option.' }, recommended: { type: 'boolean', @@ -160,7 +186,7 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { return this.createSkippedResult(questions); } - const carousel = this.toQuestionCarousel(questions); + const { carousel, idToHeaderMap } = this.toQuestionCarousel(questions); this.chatService.appendProgress(request, carousel); const answerResult = await raceCancellation(carousel.completion.p, token); @@ -170,7 +196,7 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { progress.report({ message: localize('askQuestionsTool.progress', 'Analyzing your answers...') }); - const converted = this.convertCarouselAnswers(questions, answerResult?.answers); + const converted = this.convertCarouselAnswers(questions, answerResult?.answers, idToHeaderMap); const { answeredCount, skippedCount, freeTextCount, recommendedAvailableCount, recommendedSelectedCount } = this.collectMetrics(questions, converted); this.sendTelemetry(invocation.chatRequestId, questions.length, answeredCount, skippedCount, freeTextCount, recommendedAvailableCount, recommendedSelectedCount, stopWatch.elapsed()); @@ -194,6 +220,11 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { if (question.options && question.options.length === 1) { throw new Error(localize('askQuestionsTool.invalidOptions', 'Question "{0}" must have at least two options, or none for free text input.', question.header)); } + + // Apply hard limits to truncate display values that exceed the more lenient hard limit + // Note: The original header is preserved and used as the answer key in convertCarouselAnswers + // to avoid collisions when distinct headers become identical after truncation + (question as { question: string }).question = truncateToLimit(question.question, HardLimits.question) ?? question.question; } const questionCount = questions.length; @@ -236,12 +267,16 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { return { request, sessionResource: chatSessionResource }; } - private toQuestionCarousel(questions: IQuestion[]): ChatQuestionCarouselData { - const mappedQuestions = questions.map(question => this.toChatQuestion(question)); - return new ChatQuestionCarouselData(mappedQuestions, true, generateUuid()); + private toQuestionCarousel(questions: IQuestion[]): { carousel: ChatQuestionCarouselData; idToHeaderMap: Map } { + const idToHeaderMap = new Map(); + const mappedQuestions = questions.map(question => this.toChatQuestion(question, idToHeaderMap)); + return { + carousel: new ChatQuestionCarouselData(mappedQuestions, true, generateUuid()), + idToHeaderMap + }; } - private toChatQuestion(question: IQuestion): IChatQuestion { + private toChatQuestion(question: IQuestion, idToHeaderMap: Map): IChatQuestion { let type: IChatQuestion['type']; if (!question.options || question.options.length === 0) { type = 'text'; @@ -259,10 +294,18 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { } } + // Use a stable UUID as the internal ID to avoid collisions when truncating headers + // The original header is preserved in idToHeaderMap for answer correlation + const internalId = generateUuid(); + idToHeaderMap.set(internalId, question.header); + + // Truncate header for display only + const displayTitle = truncateToLimit(question.header, HardLimits.header) ?? question.header; + return { - id: question.header, + id: internalId, type, - title: question.header, + title: displayTitle, message: question.question, options: question.options?.map(opt => ({ id: opt.label, @@ -274,7 +317,7 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { }; } - protected convertCarouselAnswers(questions: IQuestion[], carouselAnswers: Record | undefined): IAnswerResult { + protected convertCarouselAnswers(questions: IQuestion[], carouselAnswers: Record | undefined, idToHeaderMap: Map): IAnswerResult { const result: IAnswerResult = { answers: {} }; if (carouselAnswers) { @@ -282,6 +325,12 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { this.logService.trace(`[AskQuestionsTool] Question headers: ${questions.map(q => q.header).join(', ')}`); } + // Build a reverse map: original header -> internal ID + const headerToIdMap = new Map(); + for (const [internalId, originalHeader] of idToHeaderMap) { + headerToIdMap.set(originalHeader, internalId); + } + for (const question of questions) { if (!carouselAnswers) { result.answers[question.header] = { @@ -292,8 +341,10 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { continue; } - const answer = carouselAnswers[question.header]; - this.logService.trace(`[AskQuestionsTool] Processing question "${question.header}", raw answer: ${JSON.stringify(answer)}, type: ${typeof answer}`); + // Look up the answer using the internal ID that was used in the carousel + const internalId = headerToIdMap.get(question.header); + const answer = internalId ? carouselAnswers[internalId] : undefined; + this.logService.trace(`[AskQuestionsTool] Processing question "${question.header}" (internal ID: ${internalId}), raw answer: ${JSON.stringify(answer)}, type: ${typeof answer}`); if (answer === undefined) { result.answers[question.header] = { diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts index 96ea253b75497..d77bd07c0c0f5 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts @@ -17,6 +17,8 @@ export interface ILanguageModelToolConfirmationActions { detail?: string; /** Show a separator before this action */ divider?: boolean; + /** The scope of this action, if applicable */ + scope?: 'session' | 'workspace' | 'profile'; /** Selects this action. Resolves true if the action should be confirmed after selection */ select(): Promise; } diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts index 775ad671cec74..a552eb7855633 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts @@ -110,6 +110,11 @@ suite('PromptValidator', () => { disposables.add(toolService.registerToolData(conflictTool2)); disposables.add(conflictToolSet2.addTool(conflictTool2)); + // Tool in the vscode toolset with a legacy name — for testing namespaced deprecated name resolution + const toolInVscodeSet = { id: 'browserTool', toolReferenceName: 'openIntegratedBrowser', legacyToolReferenceFullNames: ['openSimpleBrowser'], displayName: 'Open Integrated Browser', canBeReferencedInPrompt: true, modelDescription: 'Open browser', source: ToolDataSource.Internal, inputSchema: {} } satisfies IToolData; + disposables.add(toolService.registerToolData(toolInVscodeSet)); + disposables.add(toolService.vscodeToolSet.addTool(toolInVscodeSet)); + instaService.set(ILanguageModelToolsService, toolService); const testModels: ILanguageModelChatMetadata[] = [ @@ -500,6 +505,41 @@ suite('PromptValidator', () => { assert.strictEqual(markers[0].message, expectedMessage); }); + test('namespaced deprecated tool name in tools header shows rename hint', async () => { + // When a tool is in a toolset (e.g. vscode/openIntegratedBrowser) and has a legacy name, + // using the namespaced old name (vscode/openSimpleBrowser) should show the rename hint + const content = [ + '---', + 'description: "Test"', + `tools: ['vscode/openSimpleBrowser']`, + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Info, message: `Tool or toolset 'vscode/openSimpleBrowser' has been renamed, use 'vscode/openIntegratedBrowser' instead.` }, + ] + ); + }); + + test('bare deprecated tool name in tools header also shows rename hint', async () => { + // The bare (non-namespaced) legacy name should also resolve + const content = [ + '---', + 'description: "Test"', + `tools: ['openSimpleBrowser']`, + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Info, message: `Tool or toolset 'openSimpleBrowser' has been renamed, use 'vscode/openIntegratedBrowser' instead.` }, + ] + ); + }); + test('unknown attribute in agent file', async () => { const content = [ '---', @@ -1531,7 +1571,7 @@ suite('PromptValidator', () => { assert.deepEqual(actual, [ { message: `Unknown tool or toolset 'ms-azuretools.vscode-azure-github-copilot/azure_recommend_custom_modes'.`, startColumn: 7, endColumn: 77 }, { message: `Tool or toolset 'github.vscode-pull-request-github/suggest-fix' also needs to be enabled in the header.`, startColumn: 7, endColumn: 52 }, - { message: `Unknown tool or toolset 'openSimpleBrowser'.`, startColumn: 7, endColumn: 24 }, + { message: `Tool or toolset 'openSimpleBrowser' has been renamed, use 'vscode/openIntegratedBrowser' instead.`, startColumn: 7, endColumn: 24 }, ]); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts index a2dcac90b3fbb..f642745892e92 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts @@ -2244,6 +2244,30 @@ suite('LanguageModelToolsService', () => { assert.strictEqual(deprecatedNames.get('userToolSetRefName'), undefined); }); + test('getDeprecatedFullReferenceNames includes namespaced legacy names for tools in toolsets', () => { + // When a tool is in a toolset and has legacy names, the deprecated names map + // should also include the namespaced form (e.g. 'vscode/oldName' → 'vscode/newName') + const toolWithLegacy: IToolData = { + id: 'myNewBrowser', + toolReferenceName: 'openIntegratedBrowser', + legacyToolReferenceFullNames: ['openSimpleBrowser'], + modelDescription: 'Open browser', + displayName: 'Open Integrated Browser', + source: ToolDataSource.Internal, + canBeReferencedInPrompt: true, + }; + store.add(service.registerToolData(toolWithLegacy)); + store.add(service.vscodeToolSet.addTool(toolWithLegacy)); + + const deprecated = service.getDeprecatedFullReferenceNames(); + + // The simple legacy name should map to the full reference name + assert.deepStrictEqual(deprecated.get('openSimpleBrowser'), new Set(['vscode/openIntegratedBrowser'])); + + // The namespaced legacy name should also map to the full reference name + assert.deepStrictEqual(deprecated.get('vscode/openSimpleBrowser'), new Set(['vscode/openIntegratedBrowser'])); + }); + test('getToolByFullReferenceName', () => { setupToolsForTest(service, store); diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatQuestionCarouselData.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatQuestionCarouselData.test.ts new file mode 100644 index 0000000000000..1c022960b237a --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/model/chatQuestionCarouselData.test.ts @@ -0,0 +1,133 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { timeout } from '../../../../../../base/common/async.js'; +import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { ChatQuestionCarouselData } from '../../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; +import { IChatQuestion } from '../../../common/chatService/chatService.js'; + +suite('ChatQuestionCarouselData', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + function createQuestions(): IChatQuestion[] { + return [ + { id: 'q1', type: 'text', title: 'Question 1' }, + { id: 'q2', type: 'singleSelect', title: 'Question 2', options: [{ id: 'a', label: 'A', value: 'a' }] } + ]; + } + + test('creates a carousel with DeferredPromise completion', () => { + const carousel = new ChatQuestionCarouselData(createQuestions(), true, 'test-resolve-id'); + + assert.strictEqual(carousel.kind, 'questionCarousel'); + assert.strictEqual(carousel.resolveId, 'test-resolve-id'); + assert.ok(carousel.completion, 'Should have completion promise'); + assert.strictEqual(carousel.completion.isSettled, false, 'Completion should not be settled initially'); + }); + + test('completion promise can be resolved with answers', async () => { + const carousel = new ChatQuestionCarouselData(createQuestions(), true, 'test-resolve-id'); + + const answers = { q1: 'answer1', q2: 'a' }; + carousel.completion.complete({ answers }); + + const result = await carousel.completion.p; + assert.strictEqual(carousel.completion.isSettled, true, 'Completion should be settled'); + assert.deepStrictEqual(result.answers, answers); + }); + + test('completion promise can be resolved with undefined (skipped)', async () => { + const carousel = new ChatQuestionCarouselData(createQuestions(), true, 'test-resolve-id'); + + carousel.completion.complete({ answers: undefined }); + + const result = await carousel.completion.p; + assert.strictEqual(carousel.completion.isSettled, true, 'Completion should be settled'); + assert.strictEqual(result.answers, undefined, 'Skipped carousel should have undefined answers'); + }); + + test('toJSON strips the completion promise', () => { + const carousel = new ChatQuestionCarouselData(createQuestions(), true, 'test-resolve-id', { q1: 'saved' }, true); + + const json = carousel.toJSON(); + + assert.strictEqual(json.kind, 'questionCarousel'); + assert.strictEqual(json.resolveId, 'test-resolve-id'); + assert.deepStrictEqual(json.data, { q1: 'saved' }); + assert.strictEqual(json.isUsed, true); + assert.strictEqual((json as { completion?: unknown }).completion, undefined, 'toJSON should not include completion'); + }); + + test('multiple carousels can have independent completion promises', async () => { + const carousel1 = new ChatQuestionCarouselData(createQuestions(), true, 'resolve-1'); + const carousel2 = new ChatQuestionCarouselData(createQuestions(), true, 'resolve-2'); + + // Complete carousel2 first + carousel2.completion.complete({ answers: { q1: 'answer2' } }); + + assert.strictEqual(carousel1.completion.isSettled, false, 'Carousel 1 should not be settled'); + assert.strictEqual(carousel2.completion.isSettled, true, 'Carousel 2 should be settled'); + + // Now complete carousel1 + carousel1.completion.complete({ answers: { q1: 'answer1' } }); + + const result1 = await carousel1.completion.p; + const result2 = await carousel2.completion.p; + + assert.deepStrictEqual(result1.answers, { q1: 'answer1' }); + assert.deepStrictEqual(result2.answers, { q1: 'answer2' }); + }); + + suite('Parallel Carousel Handling', () => { + test('when carousel is superseded, completing with undefined does not block', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + // This simulates the scenario where parallel subagents call askQuestions + // and the first carousel is superseded by the second + const carousel1 = new ChatQuestionCarouselData(createQuestions(), true, 'resolve-1'); + const carousel2 = new ChatQuestionCarouselData(createQuestions(), true, 'resolve-2'); + + // Simulate carousel1 being superseded - we complete it with undefined (skipped) + carousel1.completion.complete({ answers: undefined }); + + // Now complete carousel2 normally + carousel2.completion.complete({ answers: { q1: 'answer2' } }); + + const timeoutPromise1 = timeout(100); + const timeoutPromise2 = timeout(100); + + try { + // Both should complete without blocking + const [result1, result2] = await Promise.all([ + Promise.race([carousel1.completion.p, timeoutPromise1.then(() => 'timeout')]), + Promise.race([carousel2.completion.p, timeoutPromise2.then(() => 'timeout')]) + ]); + + assert.notStrictEqual(result1, 'timeout', 'Carousel 1 should not timeout'); + assert.notStrictEqual(result2, 'timeout', 'Carousel 2 should not timeout'); + assert.deepStrictEqual((result1 as { answers: unknown }).answers, undefined); + assert.deepStrictEqual((result2 as { answers: unknown }).answers, { q1: 'answer2' }); + } finally { + timeoutPromise1.cancel(); + timeoutPromise2.cancel(); + } + }); + }); + + test('completing an already settled carousel is safe', () => { + const carousel = new ChatQuestionCarouselData(createQuestions(), true, 'resolve-1'); + + // Complete once + carousel.completion.complete({ answers: { q1: 'first' } }); + assert.strictEqual(carousel.completion.isSettled, true); + + // Completing again should not throw + assert.doesNotThrow(() => { + carousel.completion.complete({ answers: { q1: 'second' } }); + }); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/askQuestionsTool.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/askQuestionsTool.test.ts index cf380b921ba3d..f82b6bbe55dbd 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/askQuestionsTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/askQuestionsTool.test.ts @@ -12,7 +12,13 @@ import { IChatService } from '../../../../common/chatService/chatService.js'; class TestableAskQuestionsTool extends AskQuestionsTool { public testConvertCarouselAnswers(questions: IQuestion[], carouselAnswers: Record | undefined): IAnswerResult { - return this.convertCarouselAnswers(questions, carouselAnswers); + // Create an identity map where each header is also the internal ID + // This simulates the simple case for testing the answer conversion logic + const idToHeaderMap = new Map(); + for (const q of questions) { + idToHeaderMap.set(q.header, q.header); + } + return this.convertCarouselAnswers(questions, carouselAnswers, idToHeaderMap); } } diff --git a/src/vs/workbench/contrib/mcp/common/mcpGatewayToolBrokerChannel.ts b/src/vs/workbench/contrib/mcp/common/mcpGatewayToolBrokerChannel.ts index 796de07cf62bf..88a1da8b4b6d8 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpGatewayToolBrokerChannel.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpGatewayToolBrokerChannel.ts @@ -8,8 +8,10 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { IServerChannel } from '../../../../base/parts/ipc/common/ipc.js'; -import { IMcpServer, IMcpService, McpServerCacheState, McpToolVisibility } from './mcpTypes.js'; +import { IGatewayCallToolResult, IGatewayServerResources, IGatewayServerResourceTemplates } from '../../../../platform/mcp/common/mcpGateway.js'; import { MCP } from '../../../../platform/mcp/common/modelContextProtocol.js'; +import { McpServer } from './mcpServer.js'; +import { IMcpServer, IMcpService, McpCapability, McpServerCacheState, McpToolVisibility } from './mcpTypes.js'; import { startServerAndWaitForLiveTools } from './mcpTypesUtils.js'; interface ICallToolArgs { @@ -17,32 +19,74 @@ interface ICallToolArgs { args: Record; } +interface IReadResourceArgs { + serverIndex: number; + uri: string; +} + export class McpGatewayToolBrokerChannel extends Disposable implements IServerChannel { private readonly _onDidChangeTools = this._register(new Emitter()); + private readonly _onDidChangeResources = this._register(new Emitter()); + private readonly _serverIdMap = new Map(); + private _nextServerIndex = 0; constructor( private readonly _mcpService: IMcpService, ) { super(); - let initialized = false; + let toolsInitialized = false; this._register(autorun(reader => { for (const server of this._mcpService.servers.read(reader)) { server.tools.read(reader); } - if (initialized) { + if (toolsInitialized) { this._onDidChangeTools.fire(); } else { - initialized = true; + toolsInitialized = true; + } + })); + + let resourcesInitialized = false; + this._register(autorun(reader => { + for (const server of this._mcpService.servers.read(reader)) { + server.capabilities.read(reader); + } + + if (resourcesInitialized) { + this._onDidChangeResources.fire(); + } else { + resourcesInitialized = true; } })); } + private _getServerIndex(server: IMcpServer): number { + const defId = server.definition.id; + let index = this._serverIdMap.get(defId); + if (index === undefined) { + index = this._nextServerIndex++; + this._serverIdMap.set(defId, index); + } + return index; + } + + private _getServerByIndex(serverIndex: number): IMcpServer | undefined { + for (const server of this._mcpService.servers.get()) { + if (this._getServerIndex(server) === serverIndex) { + return server; + } + } + return undefined; + } + listen(_ctx: unknown, event: string): Event { switch (event) { case 'onDidChangeTools': return this._onDidChangeTools.event as Event; + case 'onDidChangeResources': + return this._onDidChangeResources.event as Event; } throw new Error(`Invalid listen: ${event}`); @@ -59,6 +103,19 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh const result = await this._callTool(name, args || {}, cancellationToken); return result as T; } + case 'listResources': { + const resources = await this._listResources(); + return resources as T; + } + case 'readResource': { + const { serverIndex, uri } = arg as IReadResourceArgs; + const result = await this._readResource(serverIndex, uri, cancellationToken); + return result as T; + } + case 'listResourceTemplates': { + const templates = await this._listResourceTemplates(); + return templates as T; + } } throw new Error(`Invalid call: ${command}`); @@ -87,20 +144,75 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh return mcpTools; } - private async _callTool(name: string, args: Record, token: CancellationToken = CancellationToken.None): Promise { + private async _callTool(name: string, args: Record, token: CancellationToken = CancellationToken.None): Promise { for (const server of this._mcpService.servers.get()) { const tool = server.tools.get().find(t => t.definition.name === name && (t.visibility & McpToolVisibility.Model) ); if (tool) { - return tool.call(args, undefined, token); + const result = await tool.call(args, undefined, token); + return { result, serverIndex: this._getServerIndex(server) }; } } throw new Error(`Unknown tool: ${name}`); } + private async _listResources(): Promise { + const results: IGatewayServerResources[] = []; + const servers = this._mcpService.servers.get(); + await Promise.all(servers.map(async server => { + await this._ensureServerReady(server); + + const capabilities = server.capabilities.get(); + if (!capabilities || !(capabilities & McpCapability.Resources)) { + return; + } + + try { + const resources = await McpServer.callOn(server, h => h.listResources()); + results.push({ serverIndex: this._getServerIndex(server), resources }); + } catch { + // Server failed; skip + } + })); + + return results; + } + + private async _readResource(serverIndex: number, uri: string, token: CancellationToken = CancellationToken.None): Promise { + const server = this._getServerByIndex(serverIndex); + if (!server) { + throw new Error(`Unknown server index: ${serverIndex}`); + } + + return McpServer.callOn(server, h => h.readResource({ uri }, token), token); + } + + private async _listResourceTemplates(): Promise { + const results: IGatewayServerResourceTemplates[] = []; + const servers = this._mcpService.servers.get(); + + await Promise.all(servers.map(async server => { + await this._ensureServerReady(server); + + const capabilities = server.capabilities.get(); + if (!capabilities || !(capabilities & McpCapability.Resources)) { + return; + } + + try { + const resourceTemplates = await McpServer.callOn(server, h => h.listResourceTemplates()); + results.push({ serverIndex: this._getServerIndex(server), resourceTemplates }); + } catch { + // Server failed; skip + } + })); + + return results; + } + private async _ensureServerReady(server: IMcpServer): Promise { const cacheState = server.cacheState.get(); if (cacheState !== McpServerCacheState.Unknown && cacheState !== McpServerCacheState.Outdated) { diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts index ee893807ecf1c..53124c8acc37f 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IGatewayCallToolResult } from '../../../../../platform/mcp/common/mcpGateway.js'; import { MCP } from '../../common/modelContextProtocol.js'; import { McpGatewayToolBrokerChannel } from '../../common/mcpGatewayToolBrokerChannel.js'; import { IMcpIcons, IMcpServer, IMcpTool, McpConnectionState, McpServerCacheState, McpToolVisibility } from '../../common/mcpTypes.js'; @@ -60,18 +61,18 @@ suite('McpGatewayToolBrokerChannel', () => { mcpService.servers.set([serverA, serverB], undefined); - const resultA = await channel.call(undefined, 'callTool', { + const resultA = await channel.call(undefined, 'callTool', { name: 'mcp_serverA_echo', args: { name: 'one' }, }); - const resultB = await channel.call(undefined, 'callTool', { + const resultB = await channel.call(undefined, 'callTool', { name: 'mcp_serverB_echo', args: { name: 'two' }, }); assert.deepStrictEqual(invoked, ['A:one', 'B:two']); - assert.strictEqual((resultA.content[0] as MCP.TextContent).text, 'from A'); - assert.strictEqual((resultB.content[0] as MCP.TextContent).text, 'from B'); + assert.strictEqual((resultA.result.content[0] as MCP.TextContent).text, 'from A'); + assert.strictEqual((resultB.result.content[0] as MCP.TextContent).text, 'from B'); channel.dispose(); });