diff --git a/.github/skills/chat-customizations-editor/SKILL.md b/.github/skills/chat-customizations-editor/SKILL.md index 16f1d82da3a..8d4ce8edda1 100644 --- a/.github/skills/chat-customizations-editor/SKILL.md +++ b/.github/skills/chat-customizations-editor/SKILL.md @@ -79,6 +79,49 @@ Each customization type requires its own mock path in `createMockPromptsService` All test data lives in `allFiles` (prompt-based items) and the `mcpWorkspace/UserServers` arrays. Add enough items per category (8+) to invoke scrolling. +### Exercising built-in grouping + +The list widget regroups items from the default chat extension under a "Built-in" header. Three things must be in place for fixtures to exercise this: +1. Include `BUILTIN_STORAGE` in the harness descriptor's visible sources +2. Mock `IProductService.defaultChatAgent.chatExtensionId` (e.g., `'GitHub.copilot-chat'`) +3. Give mock items extension provenance via `extensionId` / `extensionDisplayName` matching that ID + +Without all three, built-in regrouping silently doesn't run and the fixture only shows flat lists. + +### Editor contribution service mocks + +The management editor embeds a `CodeEditorWidget`. Electron-side editor contributions (e.g., `AgentFeedbackEditorWidgetContribution`) are instantiated automatically and crash if their injected services aren't registered. The fixture must mock at minimum: +- `IAgentFeedbackService` — needs `onDidChangeFeedback`, `onDidChangeNavigation` as `Event.None` +- `ICodeReviewService` — needs `getReviewState()` / `getPRReviewState()` returning idle observables +- `IChatEditingService` — needs `editingSessionsObs` as empty observable +- `IAgentSessionsService` — needs `model.sessions` as empty array + +These are cross-layer imports from `vs/sessions/` — use `// eslint-disable-next-line local/code-import-patterns` on the import lines. + +### CI regression gates + +Key fixtures have `blocksCi: true` in their labels. The `screenshot-test.yml` GitHub Action captures screenshots on every PR to `main` and **fails the CI status check** if any `blocks-ci`-labeled fixture's screenshot changes. This catches layout regressions automatically. + +Currently gated fixtures: `LocalHarness`, `McpServersTab`, `McpServersTabNarrow`, `AgentsTabNarrow`. When adding a new section or layout-critical fixture, add `blocksCi: true`: + +```typescript +MyFixture: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + render: ctx => renderEditor(ctx, { ... }), +}), +``` + +Don't add `blocksCi` to every fixture — only ones that cover critical layout paths (default view, section with list + footer, narrow viewport). Too many gated fixtures creates noisy CI. + +### Screenshot stability + +Scrollbar fade transitions cause screenshot instability — the scrollbar shifts from `visible` to `invisible fade` class ~2 seconds after a programmatic scroll. After calling `revealLastItem()` or any scroll action, wait for the transition to complete before the fixture's render promise resolves: + +```typescript +await new Promise(resolve => setTimeout(resolve, 2400)); +// Then optionally poll until .scrollbar.vertical loses the 'visible' class +``` + ### Running unit tests ```bash @@ -87,3 +130,53 @@ npm run compile-check-ts-native && npm run valid-layers-check ``` See the `sessions` skill for sessions-window specific guidance. + +## Debugging Layout in the Real Product + +Component fixtures use mock data and a fixed container size. Layout bugs caused by reflow timing, real data shapes, or narrow window sizes often **don't reproduce in fixtures**. When a user reports a broken layout, debug in the live Code OSS product. + +For launching Code OSS with CDP and connecting `agent-browser`, see the **`launch` skill**. Use `--user-data-dir /tmp/code-oss-debug` to avoid colliding with an already-running instance from another worktree. + +### Navigating to the customizations editor + +After connecting, use `snapshot -i` to find the "Open Customizations" button (in the Chat panel header), then click it. To switch sections, use `eval` with a DOM click since sidebar items aren't interactive refs: + +```bash +npx agent-browser eval "const items = [...document.querySelectorAll('.section-list-item')]; \ + items.find(el => el.textContent?.includes('MCP'))?.click();" +``` + +### Inspecting widget layout + +`agent-browser eval` doesn't always print return values. Use `document.title` as a return channel: + +```bash +npx agent-browser eval "const w = document.querySelector('.mcp-list-widget'); \ + const lc = w?.querySelector('.mcp-list-container'); \ + const rows = lc?.querySelectorAll('.monaco-list-row'); \ + document.title = 'DBG:rows=' + (rows?.length ?? -1) \ + + ',listH=' + (lc?.offsetHeight ?? -1) \ + + ',seStH=' + (lc?.querySelector('.monaco-scrollable-element')?.style?.height ?? '') \ + + ',wH=' + (w?.offsetHeight ?? -1);" +npx agent-browser eval "document.title" 2>&1 +``` + +Key diagnostics: +- **`rows`** — fewer than expected means `list.layout()` never received the correct viewport height. +- **`seStH`** — empty means the list was never properly laid out. +- **`listH` vs `wH`** — list container height should be widget height minus search bar minus footer. + +### Common layout issues + +| Symptom | Root cause | Fix pattern | +|---------|-----------|-------------| +| List shows 0-1 rows in a tall container | `layout()` bailed out because `offsetHeight` returned 0 during `display:none → visible` transition | Defer layout via `DOM.getWindow(this.element).requestAnimationFrame(...)` | +| Badge or row content clips at right edge | Widget container missing `overflow: hidden` | Add `overflow: hidden` to the widget's CSS class | +| Items visible in fixture but not in product | Fixture uses many mock items; real product has few | Add fixture variants with fewer items or narrower dimensions (`width`/`height` options) | + +### Fixture vs real product gaps + +Fixtures render at a fixed size (default 900×600) with many mock items. They won't catch: +- **Reflow timing** — the real product's `display:none → visible` transition may not have reflowed before `layout()` fires +- **Narrow windows** — add narrow fixture variants (e.g., `width: 550, height: 400`) +- **Real data counts** — a user with 1 MCP server sees very different layout than a fixture with 12 diff --git a/.vscode/tasks.json b/.vscode/tasks.json index ee5f360bdb8..09ba766f0b2 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -235,30 +235,29 @@ "command": ".\\scripts\\code.bat" }, "args": [ - "--sessions" + "--sessions", + "--user-data-dir=${userHome}/.vscode-oss-sessions-dev", + "--extensions-dir=${userHome}/.vscode-oss-sessions-dev/extensions" ], "problemMatcher": [] }, + { + "label": "Transpile Client", + "type": "npm", + "script": "transpile-client", + "problemMatcher": [] + }, { "label": "Run and Compile Sessions - OSS", - "type": "shell", - "command": "npm run transpile-client && ./scripts/code.sh", - "windows": { - "command": "npm run transpile-client && .\\scripts\\code.bat" - }, - "args": [ - "--sessions" - ], + "dependsOn": ["Transpile Client", "Run Dev Sessions"], + "dependsOrder": "sequence", "inSessions": true, "problemMatcher": [] }, { "label": "Run and Compile Code - OSS", - "type": "shell", - "command": "npm run transpile-client && ./scripts/code.sh", - "windows": { - "command": "npm run transpile-client && .\\scripts\\code.bat" - }, + "dependsOn": ["Transpile Client", "Run Dev"], + "dependsOrder": "sequence", "inSessions": true, "problemMatcher": [] }, @@ -425,6 +424,17 @@ "runOptions": { "runOn": "worktreeCreated" } + }, + { + "label": "Echo E2E Status", + "type": "shell", + "command": "pwsh", + "args": [ + "-NoProfile", + "-Command", + "Write-Output \"134 passed, 0 failed, 1 skipped, 135 total\"; Start-Sleep -Seconds 2; Write-Output \"[PASS] E2E Tests\"; Write-Output \"Watching for changes...\"" + ], + "isBackground": false } ] } diff --git a/src/vs/base/test/common/fuzzyScorer.test.ts b/src/vs/base/test/common/fuzzyScorer.test.ts index f120298e22b..d7aade68c1d 100644 --- a/src/vs/base/test/common/fuzzyScorer.test.ts +++ b/src/vs/base/test/common/fuzzyScorer.test.ts @@ -1239,7 +1239,7 @@ suite('Fuzzy Scorer', () => { let [multiScore, multiMatches] = _doScore2(target, 'HelLo World'); function assertScore() { - assert.ok(multiScore ?? 0 >= ((firstSingleScore ?? 0) + (secondSingleScore ?? 0))); + assert.ok((multiScore ?? 0) >= ((firstSingleScore ?? 0) + (secondSingleScore ?? 0))); for (let i = 0; multiMatches && i < multiMatches.length; i++) { const multiMatch = multiMatches[i]; const firstAndSecondSingleMatch = firstAndSecondSingleMatches[i]; diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 2f57f7bf1d5..b10eb6aeedc 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -916,6 +916,7 @@ export class CodeApplication extends Disposable { // Ensure sessions window is open to receive the URL const windows = await windowsMainService.openSessionsWindow({ context: OpenContext.LINK, contextWindowId: undefined }); const window = windows.at(0); + window?.focus(); await window?.ready(); // Return false to let subsequent handlers (e.g., URLHandlerChannelClient) forward the URL diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index fa3d57b3a1b..0cabbc23acc 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -136,8 +136,11 @@ export class CopilotAgent extends Disposable implements IAgent { // If @vscode/ripgrep is in an .asar file, the binary is unpacked. const rgDiskPath = rgPath.replace(/\bnode_modules\.asar\b/, 'node_modules.asar.unpacked'); const rgDir = dirname(rgDiskPath); - const currentPath = env['PATH']; - env['PATH'] = currentPath ? `${currentPath}${delimiter}${rgDir}` : rgDir; + // On Windows the env key is typically "Path" (not "PATH"). Since we copied + // process.env into a plain (case-sensitive) object, we must find the actual key. + const pathKey = Object.keys(env).find(k => k.toUpperCase() === 'PATH') ?? 'PATH'; + const currentPath = env[pathKey]; + env[pathKey] = currentPath ? `${currentPath}${delimiter}${rgDir}` : rgDir; this._logService.info(`[Copilot] Resolved CLI path: ${cliPath}`); const client = new CopilotClient({ diff --git a/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts b/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts index 84a64f6d43b..4fab807cd8d 100644 --- a/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts +++ b/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts @@ -127,7 +127,6 @@ export class NativeBrowserElementsMainService extends Disposable implements INat targetWebContents.on('console-message', onConsoleMessage); targetWebContents.on('destroyed', onTargetDestroyed); windowWebContents.on('ipc-message', onIpcMessage); - token.onCancellationRequested(cleanupListeners); } /** diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index 1cf026d1c75..c2f2161f6e0 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -304,13 +304,6 @@ export interface IBrowserViewService { */ captureScreenshot(id: string, options?: IBrowserViewCaptureScreenshotOptions): Promise; - /** - * Dispatch a key event to the browser view - * @param id The browser view identifier - * @param keyEvent The key event data - */ - dispatchKeyEvent(id: string, keyEvent: IBrowserViewKeyDownEvent): Promise; - /** * Focus the browser view * @param id The browser view identifier diff --git a/src/vs/platform/browserView/electron-browser/preload-browserView.ts b/src/vs/platform/browserView/electron-browser/preload-browserView.ts index 29832f220ff..340de08af7d 100644 --- a/src/vs/platform/browserView/electron-browser/preload-browserView.ts +++ b/src/vs/platform/browserView/electron-browser/preload-browserView.ts @@ -17,7 +17,7 @@ */ (function () { - const { contextBridge } = require('electron'); + const { contextBridge, ipcRenderer } = require('electron'); // ####################################################################### // ### ### @@ -26,6 +26,37 @@ // ### (https://github.com/electron/electron/issues/25516) ### // ### ### // ####################################################################### + + // Listen for keydown events that the page did not handle and forward them for shortcut handling. + window.addEventListener('keydown', (event) => { + // Require that the event is trusted -- i.e. user-initiated. + // eslint-disable-next-line no-restricted-syntax + if (!(event instanceof KeyboardEvent) || !event.isTrusted) { + return; + } + + // If the event was already handled by the page, do not forward it. + if (event.defaultPrevented) { + return; + } + + // filter to events that either have modifiers or do not have a character representation. + if (!(event.ctrlKey || event.altKey || event.metaKey) && event.key.length === 1) { + return; + } + + ipcRenderer.send('vscode:browserView:keydown', { + key: event.key, + keyCode: event.keyCode, + code: event.code, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + metaKey: event.metaKey, + repeat: event.repeat + }); + }); + const globals = { /** * Get the currently selected text in the page. diff --git a/src/vs/platform/browserView/electron-main/browserSession.ts b/src/vs/platform/browserView/electron-main/browserSession.ts index 1a21770d068..dc23d2ee0c0 100644 --- a/src/vs/platform/browserView/electron-main/browserSession.ts +++ b/src/vs/platform/browserView/electron-main/browserSession.ts @@ -9,6 +9,7 @@ import { URI } from '../../../base/common/uri.js'; import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; import { BrowserViewStorageScope } from '../common/browserView.js'; import { BrowserSessionTrust, IBrowserSessionTrust } from './browserSessionTrust.js'; +import { FileAccess } from '../../../base/common/network.js'; // Same as webviews, minus clipboard-read const allowedPermissions = new Set([ @@ -195,7 +196,7 @@ export class BrowserSession { readonly storageScope: BrowserViewStorageScope, ) { this._trust = new BrowserSessionTrust(this); - this.configurePermissions(); + this.configure(); BrowserSession.knownSessions.add(electronSession); BrowserSession._bySession.set(electronSession, this); BrowserSession._byId.set(id, new WeakRef(this)); @@ -218,15 +219,19 @@ export class BrowserSession { } /** - * Apply the standard permission policy to the session. + * Apply the permission policy and preload scripts to the session. */ - private configurePermissions(): void { + private configure(): void { this.electronSession.setPermissionRequestHandler((_webContents, permission, callback) => { return callback(allowedPermissions.has(permission)); }); this.electronSession.setPermissionCheckHandler((_webContents, permission, _origin) => { return allowedPermissions.has(permission); }); + this.electronSession.registerPreloadScript({ + type: 'frame', + filePath: FileAccess.asFileUri('vs/platform/browserView/electron-browser/preload-browserView.js').fsPath + }); } /** diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 4113a4e7b36..a38625fa616 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -4,16 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { WebContentsView, webContents } from 'electron'; -import { FileAccess } from '../../../base/common/network.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewNewPageRequest, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, BrowserNewPageLocation, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex } from '../common/browserView.js'; -import { EVENT_KEY_CODE_MAP, KeyCode, KeyMod, SCAN_CODE_STR_TO_EVENT_KEY_CODE } from '../../../base/common/keyCodes.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { ICodeWindow } from '../../window/electron-main/window.js'; import { IAuxiliaryWindowsMainService } from '../../auxiliaryWindow/electron-main/auxiliaryWindows.js'; -import { isMacintosh } from '../../../base/common/platform.js'; import { BrowserViewUri } from '../common/browserViewUri.js'; import { BrowserViewDebugger } from './browserViewDebugger.js'; import { ILogService } from '../../log/common/log.js'; @@ -21,18 +18,7 @@ import { ICDPTarget, ICDPConnection, CDPTargetInfo } from '../common/cdp/types.j import { BrowserSession } from './browserSession.js'; import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js'; import { hasKey } from '../../../base/common/types.js'; - -/** Key combinations that are used in system-level shortcuts. */ -const nativeShortcuts = new Set([ - KeyMod.CtrlCmd | KeyCode.KeyA, - KeyMod.CtrlCmd | KeyCode.KeyC, - KeyMod.CtrlCmd | KeyCode.KeyV, - KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyV, - KeyMod.CtrlCmd | KeyCode.KeyX, - ...(isMacintosh ? [] : [KeyMod.CtrlCmd | KeyCode.KeyY]), - KeyMod.CtrlCmd | KeyCode.KeyZ, - KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyZ -]); +import { SCAN_CODE_STR_TO_EVENT_KEY_CODE } from '../../../base/common/keyCodes.js'; /** * Represents a single browser view instance with its WebContentsView and all associated logic. @@ -50,7 +36,6 @@ export class BrowserView extends Disposable implements ICDPTarget { private _debugger: BrowserViewDebugger; private _window: ICodeWindow | IAuxiliaryWindow | undefined; - private _isSendingKeyEvent = false; private _isDisposed = false; private readonly _onDidNavigate = this._register(new Emitter()); @@ -106,7 +91,6 @@ export class BrowserView extends Disposable implements ICDPTarget { sandbox: true, webviewTag: false, session: this.session.electronSession, - preload: FileAccess.asFileUri('vs/platform/browserView/electron-browser/preload-browserView.js').fsPath, // TODO@kycutler: Remove this once https://github.com/electron/electron/issues/42578 is fixed type: 'browserView' @@ -300,13 +284,41 @@ export class BrowserView extends Disposable implements ICDPTarget { this._onDidChangeFocus.fire({ focused: false }); }); - // Key down events - listen for raw key input events - webContents.on('before-input-event', async (event, input) => { - if (input.type === 'keyDown' && !this._isSendingKeyEvent) { - if (this.tryHandleCommand(input)) { - event.preventDefault(); - } + // Forward key down events that weren't handled by the page to the workbench for shortcut handling. + webContents.ipc.on('vscode:browserView:keydown', (_event, keyEvent: IBrowserViewKeyDownEvent) => { + this._onDidKeyCommand.fire(keyEvent); + }); + // If the page won't be able to handle events, forward key down events directly. + webContents.on('before-input-event', (event, input) => { + if (input.type !== 'keyDown') { + return; } + + const pageIsAvailable = this._view.getVisible() + && !webContents.isCrashed() + && !this._debugger.isPaused; + if (pageIsAvailable) { + return; + } + + // This logic should mirror that in preload-browserView.ts. + if (!(input.control || input.alt || input.meta) && input.key.length === 1) { + return; + } + + event.preventDefault(); + + const eventKeyCode = SCAN_CODE_STR_TO_EVENT_KEY_CODE[input.code] || 0; + this._onDidKeyCommand.fire({ + key: input.key, + keyCode: eventKeyCode, + code: input.code, + ctrlKey: input.control, + shiftKey: input.shift, + altKey: input.alt, + metaKey: input.meta, + repeat: input.isAutoRepeat + }); }); // Track user gestures for popup blocking logic. @@ -512,35 +524,6 @@ export class BrowserView extends Disposable implements ICDPTarget { return screenshot; } - /** - * Dispatch a keyboard event to this view - */ - async dispatchKeyEvent(keyEvent: IBrowserViewKeyDownEvent): Promise { - const event: Electron.KeyboardInputEvent = { - type: 'keyDown', - keyCode: keyEvent.key, - modifiers: [] - }; - if (keyEvent.ctrlKey) { - event.modifiers!.push('control'); - } - if (keyEvent.shiftKey) { - event.modifiers!.push('shift'); - } - if (keyEvent.altKey) { - event.modifiers!.push('alt'); - } - if (keyEvent.metaKey) { - event.modifiers!.push('meta'); - } - this._isSendingKeyEvent = true; - try { - await this._view.webContents.sendInputEvent(event); - } finally { - this._isSendingKeyEvent = false; - } - } - /** * Focus this view */ @@ -673,55 +656,6 @@ export class BrowserView extends Disposable implements ICDPTarget { super.dispose(); } - /** - * Potentially handle an input event as a VS Code command. - * Returns `true` if the event was forwarded to VS Code and should not be handled natively. - */ - private tryHandleCommand(input: Electron.Input): boolean { - const eventKeyCode = SCAN_CODE_STR_TO_EVENT_KEY_CODE[input.code] || 0; - const keyCode = EVENT_KEY_CODE_MAP[eventKeyCode] || KeyCode.Unknown; - - const isArrowKey = keyCode >= KeyCode.LeftArrow && keyCode <= KeyCode.DownArrow; - const isNonEditingKey = - keyCode === KeyCode.Escape || - keyCode >= KeyCode.F1 && keyCode <= KeyCode.F24 || - keyCode >= KeyCode.AudioVolumeMute; - - // Ignore most Alt-only inputs (often used for accented characters or menu accelerators) - const isAltOnlyInput = input.alt && !input.control && !input.meta; - if (isAltOnlyInput && !isNonEditingKey && !isArrowKey) { - return false; - } - - // Only reroute if there's a command modifier or it's a non-editing key - const hasCommandModifier = input.control || input.alt || input.meta; - if (!hasCommandModifier && !isNonEditingKey) { - return false; - } - - // Ignore Ctrl/Cmd + [A,C,V,X,Z] shortcuts to allow native handling (e.g. copy/paste) - const isControlInput = isMacintosh ? input.meta : input.control; - const modifiedKeyCode = keyCode | - (isControlInput ? KeyMod.CtrlCmd : 0) | - (input.shift ? KeyMod.Shift : 0) | - (input.alt ? KeyMod.Alt : 0); - if (nativeShortcuts.has(modifiedKeyCode)) { - return false; - } - - this._onDidKeyCommand.fire({ - key: input.key, - keyCode: eventKeyCode, - code: input.code, - ctrlKey: input.control || false, - shiftKey: input.shift || false, - altKey: input.alt || false, - metaKey: input.meta || false, - repeat: input.isAutoRepeat || false - }); - return true; - } - private _windowById(windowId: number | undefined): ICodeWindow | IAuxiliaryWindow | undefined { return this._codeWindowById(windowId) ?? this._auxiliaryWindowById(windowId); } diff --git a/src/vs/platform/browserView/electron-main/browserViewDebugger.ts b/src/vs/platform/browserView/electron-main/browserViewDebugger.ts index c0bdb734ef7..ebdbdb3bf3e 100644 --- a/src/vs/platform/browserView/electron-main/browserViewDebugger.ts +++ b/src/vs/platform/browserView/electron-main/browserViewDebugger.ts @@ -20,6 +20,10 @@ export class BrowserViewDebugger extends Disposable implements ICDPTarget { /** Map from CDP sessionId to the per-connection event emitter */ private readonly _sessions = this._register(new DisposableMap()); + /** Whether any attached debugger session has paused JavaScript execution. */ + private _isPaused = false; + get isPaused(): boolean { return this._isPaused; } + /** * The real CDP targetId discovered from Target.getTargets(). * Ideally this could be fetched synchronously from the WebContents, @@ -141,6 +145,13 @@ export class BrowserViewDebugger extends Disposable implements ICDPTarget { return; } + // Track debugger pause state + if (method === 'Debugger.paused') { + this._isPaused = true; + } else if (method === 'Debugger.resumed') { + this._isPaused = false; + } + // Find the session for this sessionId and fire the event const session = this._sessions.get(sessionId); if (session) { diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index cc3635e5dfa..d7e9ac1737f 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -6,7 +6,7 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { IBrowserViewBounds, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewService, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId } from '../common/browserView.js'; +import { IBrowserViewBounds, IBrowserViewState, IBrowserViewService, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId } from '../common/browserView.js'; import { clipboard, Menu, MenuItem } from 'electron'; import { ICDPTarget, CDPBrowserVersion, CDPWindowBounds, CDPTargetInfo, ICDPConnection, ICDPBrowserTarget } from '../common/cdp/types.js'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; @@ -304,10 +304,6 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).captureScreenshot(options); } - async dispatchKeyEvent(id: string, keyEvent: IBrowserViewKeyDownEvent): Promise { - return this._getBrowserView(id).dispatchKeyEvent(keyEvent); - } - async focus(id: string): Promise { return this._getBrowserView(id).focus(); } diff --git a/src/vs/platform/terminal/common/terminal.ts b/src/vs/platform/terminal/common/terminal.ts index 44e53e09a64..dc7c596c346 100644 --- a/src/vs/platform/terminal/common/terminal.ts +++ b/src/vs/platform/terminal/common/terminal.ts @@ -122,7 +122,6 @@ export const enum TerminalSettingId { FontLigaturesFallbackLigatures = 'terminal.integrated.fontLigatures.fallbackLigatures', EnableKittyKeyboardProtocol = 'terminal.integrated.enableKittyKeyboardProtocol', EnableWin32InputMode = 'terminal.integrated.enableWin32InputMode', - ExperimentalAiProfileGrouping = 'terminal.integrated.experimental.aiProfileGrouping', AllowInUntrustedWorkspace = 'terminal.integrated.allowInUntrustedWorkspace', // Developer/debug settings diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index fd9c90002c3..ad5ba6ba3d9 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -100,23 +100,12 @@ box-sizing: border-box; } -/* ---- Session List ---- */ - -.agent-sessions-workbench .agent-session-title { - color: var(--vscode-list-activeSelectionForeground); -} - /* ---- Modal Editor Block ---- */ .agent-sessions-workbench .monaco-modal-editor-block { background: rgba(0, 0, 0, 0.5); } -/* Hide the file icon in modal editor titles */ -.agent-sessions-workbench .modal-editor-title .monaco-icon-label::before, -.agent-sessions-workbench .modal-editor-title .monaco-icon-label > .monaco-icon-label-iconpath { - display: none; -} /* ---- Customization Empty State ---- */ @@ -148,6 +137,90 @@ flex-shrink: 0; } +/* ---- Part Appear Transitions ---- */ + +/* + * Subtle appear animation when parts transition from display:none → visible + * (via split-view-view .visible class). + * + * Animated properties: opacity, margin, border-color, background. + * Opacity transiently creates a stacking context while it animates from 0 to 1 + * over 250ms — once settled at opacity: 1, no additional stacking context is + * introduced by this animation. Margin shifts are purely visual within the + * grid-allocated space. + */ + +.agent-sessions-workbench .part.sidebar, +.agent-sessions-workbench .part.auxiliarybar, +.agent-sessions-workbench .part.panel, +.agent-sessions-workbench .part.chatbar { + transition: + opacity 250ms ease-out, + margin-top 250ms ease-out, + margin-right 250ms ease-out, + margin-bottom 250ms ease-out, + border-color 250ms ease-out, + background 250ms ease-out; +} + +/* Sidebar & auxiliary bar also transition margin-left */ +.agent-sessions-workbench .part.sidebar, +.agent-sessions-workbench .part.auxiliarybar { + transition: + opacity 250ms ease-out, + margin 250ms ease-out, + border-color 250ms ease-out, + background 250ms ease-out; +} + +@starting-style { + /* Shared starting values */ + .agent-sessions-workbench .part.sidebar, + .agent-sessions-workbench .part.auxiliarybar, + .agent-sessions-workbench .part.panel, + .agent-sessions-workbench .part.chatbar { + opacity: 0; + border-color: transparent; + } + + /* Card parts: blend from surrounding background */ + .agent-sessions-workbench .part.auxiliarybar, + .agent-sessions-workbench .part.panel, + .agent-sessions-workbench .part.chatbar { + background: color-mix(in srgb, var(--part-background) 60%, var(--vscode-sideBar-background)); + } + + /* Per-part margin shifts — each part settles into its resting margin */ + /* Sidebar (left): slides in from 6px left → margin: 0 */ + .agent-sessions-workbench .part.sidebar { + margin-left: -6px; + } + + /* Panel (bottom): slides down from 6px above → margin: 0 16px 18px 16px */ + .agent-sessions-workbench .part.panel { + margin: 6px 16px 18px 16px; + } + + /* Auxiliary bar (right): slides in from 6px right → margin: 0 16px 2px 0 */ + .agent-sessions-workbench .part.auxiliarybar { + margin: 0 16px 2px 6px; + } + + /* Chat bar (center-bottom): slides up from 6px below → margin: 0 16px 2px 16px */ + .agent-sessions-workbench .part.chatbar { + margin: 6px 16px 2px 16px; + } +} + +@media (prefers-reduced-motion: reduce) { + .agent-sessions-workbench .part.sidebar, + .agent-sessions-workbench .part.auxiliarybar, + .agent-sessions-workbench .part.panel, + .agent-sessions-workbench .part.chatbar { + transition: none; + } +} + /* ---- Widget Customizations ---- */ /* Badge */ diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 4c3c01bc186..3778ad29ecd 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -10,7 +10,6 @@ import { ICompressedTreeNode } from '../../../../base/browser/ui/tree/compressed import { ICompressibleTreeRenderer } from '../../../../base/browser/ui/tree/objectTree.js'; import { IObjectTreeElement, ITreeNode } from '../../../../base/browser/ui/tree/tree.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Iterable } from '../../../../base/common/iterator.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { autorun, constObservable, derived, derivedOpts, IObservable, IObservableWithChange, ISettableObservable, ObservablePromise, observableSignalFromEvent, observableValue, runOnChange } from '../../../../base/common/observable.js'; @@ -659,7 +658,6 @@ export class ChangesViewPane extends ViewPane { this.renderDisposables.add(scopedInstantiationService); this.renderDisposables.add(autorun(reader => { - const { added, removed } = topLevelStats.read(reader); const outgoingChanges = outgoingChangesObs.read(reader); const sessionResource = this.viewModel.activeSessionResourceObs.read(reader); @@ -700,11 +698,7 @@ export class ChangesViewPane extends ViewPane { : { shouldForwardArgs: true }, buttonConfigProvider: (action) => { if (action.id === 'chatEditing.viewChanges' || action.id === 'chatEditing.viewPreviousEdits' || action.id === 'chatEditing.viewAllSessionChanges' || action.id === 'chat.openSessionWorktreeInVSCode') { - const diffStatsLabel = new MarkdownString( - `+${added} -${removed}`, - { supportHtml: true } - ); - return { showIcon: true, showLabel: true, isSecondary: true, customClass: 'working-set-diff-stats', customLabel: diffStatsLabel }; + return { showIcon: true, showLabel: false, isSecondary: true }; } if (action.id === RUN_SESSION_CODE_REVIEW_ACTION_ID) { if (codeReviewLoading) { diff --git a/src/vs/sessions/contrib/changes/browser/media/changesView.css b/src/vs/sessions/contrib/changes/browser/media/changesView.css index 1b187233832..ce34dce6c7c 100644 --- a/src/vs/sessions/contrib/changes/browser/media/changesView.css +++ b/src/vs/sessions/contrib/changes/browser/media/changesView.css @@ -260,21 +260,6 @@ color: var(--vscode-chat-linesRemovedForeground); } -/* Line counts in buttons */ -.changes-view-body .chat-editing-session-actions .monaco-button.working-set-diff-stats { - flex-shrink: 0; - padding-left: 4px; - padding-right: 8px; -} - -.changes-view-body .chat-editing-session-actions .monaco-button .working-set-lines-added { - color: var(--vscode-chat-linesAddedForeground); -} - -.changes-view-body .chat-editing-session-actions .monaco-button .working-set-lines-removed { - color: var(--vscode-chat-linesRemovedForeground); -} - .changes-view-body .chat-editing-session-actions .monaco-button.code-review-comments, .changes-view-body .chat-editing-session-actions .monaco-button.code-review-loading { padding-left: 4px; diff --git a/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts b/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts index 89486a53fd9..79dbb0ba93f 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; +import { IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; import { joinPath, dirname, isEqual } from '../../../../base/common/resources.js'; import { parse } from '../../../../base/common/jsonc.js'; import { URI } from '../../../../base/common/uri.js'; @@ -127,7 +127,6 @@ export class SessionsConfigurationService extends Disposable implements ISession private static readonly _PINNED_TASK_LABELS_KEY = 'agentSessions.pinnedTaskLabels'; private readonly _sessionTasks = observableValue(this, []); private readonly _fileWatcher = this._register(new MutableDisposable()); - private readonly _knownSessionWorktrees = new Map(); private readonly _pinnedTaskLabels: Map; private readonly _pinnedTaskObservables = new Map>>(); @@ -145,11 +144,6 @@ export class SessionsConfigurationService extends Disposable implements ISession ) { super(); this._pinnedTaskLabels = this._loadPinnedTaskLabels(); - - this._register(autorun(reader => { - const activeSession = this._sessionsManagementService.activeSession.read(reader); - this._handleActiveSessionChange(activeSession); - })); } getSessionTasks(session: IActiveSessionItem): IObservable { @@ -384,65 +378,10 @@ export class SessionsConfigurationService extends Disposable implements ISession } } - private async _readAllTasks(session: IActiveSessionItem): Promise { - const result: ITaskEntry[] = []; - - // Read workspace tasks - const workspaceUri = this._getTasksJsonUri(session, 'workspace'); - if (workspaceUri) { - const workspaceJson = await this._readTasksJson(workspaceUri); - if (workspaceJson.tasks) { - result.push(...workspaceJson.tasks.filter(t => this._isSupportedTask(t))); - } - } - - // Read user tasks - const userUri = this._getTasksJsonUri(session, 'user'); - if (userUri) { - const userJson = await this._readTasksJson(userUri); - if (userJson.tasks) { - result.push(...userJson.tasks.filter(t => this._isSupportedTask(t))); - } - } - - return result; - } - private _isSupportedTask(task: ITaskEntry): boolean { return !!task.label; } - private _handleActiveSessionChange(session: IActiveSessionItem | undefined): void { - if (!session) { - return; - } - - const sessionKey = session.resource.toString(); - const currentWorktree = session.worktree?.toString(); - if (!this._knownSessionWorktrees.has(sessionKey)) { - this._knownSessionWorktrees.set(sessionKey, currentWorktree); - return; - } - - const previousWorktree = this._knownSessionWorktrees.get(sessionKey); - this._knownSessionWorktrees.set(sessionKey, currentWorktree); - if (!currentWorktree || previousWorktree === currentWorktree) { - return; - } - - void this._runWorktreeCreatedTasks(session); - } - - private async _runWorktreeCreatedTasks(session: IActiveSessionItem): Promise { - const tasks = await this._readAllTasks(session); - for (const task of tasks) { - if (!task.inSessions || task.runOptions?.runOn !== 'worktreeCreated') { - continue; - } - await this.runTask(task, session); - } - } - private _ensureFileWatch(folder: URI): void { const tasksUri = joinPath(folder, '.vscode', 'tasks.json'); if (this._watchedResource && this._watchedResource.toString() === tasksUri.toString()) { diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts index 5569ec2600e..ce68824747b 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts @@ -615,26 +615,4 @@ suite('SessionsConfigurationService', () => { assert.strictEqual(ranTasks.length, 1); assert.strictEqual(ranTasks[0].label, 'build'); }); - - test('runs worktreeCreated session tasks when a session gains a worktree', async () => { - registerMockTask('build', worktreeUri); - const sessionResource = URI.parse('file:///session-worktree-created'); - const worktreeTasksUri = URI.parse('file:///worktree/.vscode/tasks.json'); - const userTasksUri = URI.from({ scheme: userSettingsUri.scheme, path: '/user/tasks.json' }); - fileContents.set(worktreeTasksUri.toString(), tasksJsonContent([ - { label: 'build', type: 'shell', command: 'npm run build', inSessions: true, runOptions: { runOn: 'worktreeCreated' } }, - makeTask('manual', 'npm test', true), - ])); - fileContents.set(userTasksUri.toString(), tasksJsonContent([])); - - activeSessionObs.set({ ...makeSession({ repository: repoUri }), resource: sessionResource }, undefined); - await new Promise(r => setTimeout(r, 10)); - - activeSessionObs.set({ ...makeSession({ repository: repoUri, worktree: worktreeUri }), resource: sessionResource }, undefined); - await new Promise(r => setTimeout(r, 10)); - - assert.strictEqual(ranTasks.length, 1); - assert.strictEqual(ranTasks[0].label, 'build'); - }); - }); diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css index 567da1a2e66..36e5bb61af0 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css @@ -11,7 +11,7 @@ flex-wrap: nowrap; align-items: center; justify-content: flex-start; - padding: 0 16px; + padding-left: 16px; height: 22px; border-radius: 4px; -webkit-app-region: no-drag; diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css index d0d590f70e1..f2f6f71e1be 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css @@ -96,3 +96,44 @@ overflow: hidden; } } + +/* Sessions-app-specific overrides for the agent sessions viewer. + * These styles only apply within the agent-sessions-workbench context. */ + +.agent-sessions-workbench { + + /* + * Show-more / show-less: content always rendered, list row height + * controls visibility (1px = clipped, 26px = visible). + * Height animation is driven by JS (requestAnimationFrame) since + * the virtualized list uses absolute positioning. + */ + .agent-session-show-more { + justify-content: center; + align-items: center; + padding: 0 6px; + font-size: 11px; + color: var(--vscode-descriptionForeground); + min-height: 26px; + + .agent-session-show-more-label { + padding: 0 6px; + flex-shrink: 0; + white-space: nowrap; + } + + /* Lines on both sides of the text as flex items */ + &::before, + &::after { + content: ''; + flex: 1; + height: 1px; + background: var(--vscode-widget-border, var(--vscode-contrastBorder)); + } + } + + /* Brighter text on direct hover */ + .monaco-list-row:hover .agent-session-show-more:hover { + color: var(--vscode-foreground); + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index 14a681d901a..29d1542a846 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -14,13 +14,18 @@ import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../work import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { SessionsTitleBarContribution } from './sessionsTitleBarWidget.js'; import { AgenticSessionsViewPane, SessionsViewId } from './sessionsViewPane.js'; -import { SessionsManagementService, ISessionsManagementService } from './sessionsManagementService.js'; +import { SessionsManagementService, ISessionsManagementService, IsNewChatSessionContext } from './sessionsManagementService.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { AgentSessionSection, IAgentSessionSection, isAgentSessionSection } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { NewChatViewPane, SessionsViewId as NewChatViewId } from '../../chat/browser/newChatViewPane.js'; +import { Menus } from '../../../browser/menus.js'; const agentSessionsViewIcon = registerIcon('chat-sessions-icon', Codicon.commentDiscussionSparkle, localize('agentSessionsViewIcon', 'Icon for Agent Sessions View')); const AGENT_SESSIONS_VIEW_TITLE = localize2('agentSessions.view.label', "Sessions"); @@ -55,6 +60,43 @@ registerWorkbenchContribution2(SessionsTitleBarContribution.ID, SessionsTitleBar registerSingleton(ISessionsManagementService, SessionsManagementService, InstantiationType.Delayed); +registerAction2(class MarkSessionAsDoneAction extends Action2 { + + constructor() { + super({ + id: 'agentSession.markAsDone', + title: localize2('markAsDone', "Mark as Done"), + icon: Codicon.check, + menu: [{ + id: Menus.CommandCenter, + order: 102, + when: ContextKeyExpr.and( + IsAuxiliaryWindowContext.negate(), + SessionsWelcomeVisibleContext.negate(), + IsNewChatSessionContext.negate() + ) + }] + }); + } + + async run(accessor: ServicesAccessor): Promise { + const sessionsManagementService = accessor.get(ISessionsManagementService); + const agentSessionsService = accessor.get(IAgentSessionsService); + + const activeSession = sessionsManagementService.getActiveSession(); + if (!activeSession || activeSession.isUntitled) { + return; + } + + const agentSession = agentSessionsService.getSession(activeSession.resource); + if (!agentSession || agentSession.isArchived()) { + return; + } + + agentSession.setArchived(true); + } +}); + registerAction2(class NewSessionForRepositoryAction extends Action2 { constructor() { diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index d568036ca54..a2745880adb 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -336,7 +336,22 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { return undefined; } - return basename(uri); + const name = basename(uri); + + // For local repositories (file scheme), the basename is not URI-encoded and may + // legitimately contain '%' characters. Decoding in that case can throw or + // incorrectly transform sequences like '%20' into spaces, so return it as-is. + if (uri.scheme === 'file') { + return name; + } + + // For non-file schemes where the basename may be encodeURIComponent-encoded, + // attempt to decode but fall back to the raw name on any error. + try { + return decodeURIComponent(name); + } catch { + return name; + } } private _countUnreadSessions(): number { diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 880b52eae3b..ad0ed33f236 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -169,6 +169,10 @@ export class AgenticSessionsViewPane extends ViewPane { disableHover: true, enableApprovalRow: true, repositoryGroupLimit: AgentSessionsDataSource.REPOSITORY_GROUP_LIMIT, + hideSectionCount: true, + hideSessionBadge: true, + useStatusOnlyIcons: true, + compactShowMore: true, getHoverPosition: () => this.getSessionHoverPosition(), trackActiveEditorSession: () => true, collapseOlderSections: () => true, diff --git a/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts b/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts index 6b6251056df..46ea4b72e9f 100644 --- a/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts +++ b/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts @@ -245,7 +245,7 @@ export class BrowserEditorInput extends EditorInput { return { resource: this.resource, options: { - override: BrowserEditorInput.ID, + override: BrowserEditorInput.EDITOR_ID, viewState } }; diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index 7bb193a06d9..8f9cfa3f96b 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -198,7 +198,6 @@ export interface IBrowserViewModel extends IDisposable { reload(hard?: boolean): Promise; toggleDevTools(): Promise; captureScreenshot(options?: IBrowserViewCaptureScreenshotOptions): Promise; - dispatchKeyEvent(keyEvent: IBrowserViewKeyDownEvent): Promise; focus(): Promise; findInPage(text: string, options?: IBrowserViewFindInPageOptions): Promise; stopFindInPage(keepSelection?: boolean): Promise; @@ -471,10 +470,6 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { return result; } - async dispatchKeyEvent(keyEvent: IBrowserViewKeyDownEvent): Promise { - return this.browserViewService.dispatchKeyEvent(this.id, keyEvent); - } - async focus(): Promise { return this.browserViewService.focus(this.id); } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index c4f7bb4bb27..1e60e58fcd7 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -348,7 +348,6 @@ export class BrowserEditor extends EditorPane { private _overlayVisible = false; private _editorVisible = false; - private _currentKeyDownEvent: IBrowserViewKeyDownEvent | undefined; private _navigationBar!: BrowserNavigationBar; private _browserContainerWrapper!: HTMLElement; @@ -988,29 +987,14 @@ export class BrowserEditor extends EditorPane { } } - forwardCurrentEvent(): boolean { - if (this._currentKeyDownEvent && this._model) { - void this._model.dispatchKeyEvent(this._currentKeyDownEvent); - return true; - } - return false; - } - private async handleKeyEventFromBrowserView(keyEvent: IBrowserViewKeyDownEvent): Promise { - this._currentKeyDownEvent = keyEvent; - try { const syntheticEvent = new KeyboardEvent('keydown', keyEvent); const standardEvent = new StandardKeyboardEvent(syntheticEvent); - const handled = this.keybindingService.dispatchEvent(standardEvent, this._browserContainer); - if (!handled) { - this.forwardCurrentEvent(); - } + this.keybindingService.dispatchEvent(standardEvent, this._browserContainer); } catch (error) { this.logService.error('BrowserEditor.handleKeyEventFromBrowserView: Error dispatching key event', error); - } finally { - this._currentKeyDownEvent = undefined; } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts index ac62107adcd..be1e6c7d1cb 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -55,7 +55,7 @@ class BrowserEditorResolverContribution implements IWorkbenchContribution { editorResolverService.registerEditor( `${Schemas.vscodeBrowser}:/**`, { - id: BrowserEditorInput.ID, + id: BrowserEditorInput.EDITOR_ID, label: localize('browser.editorLabel', "Browser"), priority: RegisteredEditorPriority.exclusive }, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts index b501f0a66ba..b4805d349a6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts @@ -179,7 +179,7 @@ export class ArchiveAllAgentSessionsAction extends Action2 { message: sessionsToArchive.length === 1 ? localize('archiveAllSessions.confirmSingle', "Are you sure you want to archive 1 agent session?") : localize('archiveAllSessions.confirm', "Are you sure you want to archive {0} agent sessions?", sessionsToArchive.length), - detail: localize('archiveAllSessions.detail', "You can unarchive sessions later if needed from the Chat view."), + detail: localize('archiveAllSessions.detail', "You can unarchive sessions later if needed from the sessions view."), primaryButton: localize('archiveAllSessions.archive', "Archive") }); @@ -312,24 +312,24 @@ export class UnarchiveAgentSessionSectionAction extends Action2 { const dialogService = accessor.get(IDialogService); const storageService = accessor.get(IStorageService); - const skipConfirmation = storageService.getBoolean(ConfirmArchiveStorageKey, StorageScope.PROFILE, false); - if (!skipConfirmation) { - const confirmed = await dialogService.confirm({ - message: context.sessions.length === 1 - ? localize('unarchiveSectionSessions.confirmSingle', "Are you sure you want to unarchive 1 agent session?") - : localize('unarchiveSectionSessions.confirm', "Are you sure you want to unarchive {0} agent sessions?", context.sessions.length), - primaryButton: localize('unarchiveSectionSessions.unarchive', "Unarchive All"), - checkbox: { - label: localize('doNotAskAgain', "Do not ask me again") + if (context.sessions.length > 1) { + const skipConfirmation = storageService.getBoolean(ConfirmArchiveStorageKey, StorageScope.PROFILE, false); + if (!skipConfirmation) { + const confirmed = await dialogService.confirm({ + message: localize('unarchiveSectionSessions.confirm', "Are you sure you want to unarchive {0} agent sessions?", context.sessions.length), + primaryButton: localize('unarchiveSectionSessions.unarchive', "Unarchive All"), + checkbox: { + label: localize('doNotAskAgain', "Do not ask me again") + } + }); + + if (!confirmed.confirmed) { + return; } - }); - if (!confirmed.confirmed) { - return; - } - - if (confirmed.checkboxChecked) { - storageService.store(ConfirmArchiveStorageKey, true, StorageScope.PROFILE, StorageTarget.USER); + if (confirmed.checkboxChecked) { + storageService.store(ConfirmArchiveStorageKey, true, StorageScope.PROFILE, StorageTarget.USER); + } } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index 809502a5cec..ad17b336879 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -8,12 +8,12 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IOpenEvent, WorkbenchCompressibleAsyncDataTree } from '../../../../../platform/list/browser/listService.js'; -import { $, append, EventHelper, addDisposableListener, EventType, hide, setVisibility } from '../../../../../base/browser/dom.js'; +import { $, append, EventHelper, addDisposableListener, EventType, getWindow, hide, setVisibility } from '../../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { localize } from '../../../../../nls.js'; -import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, IMarshalledAgentSessionContext, isAgentSession, isAgentSessionSection, isAgentSessionShowMore } from './agentSessionsModel.js'; -import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionSectionLabels, AgentSessionShowMoreRenderer, AgentSessionsSorter, getRepositoryName, IAgentSessionsFilter } from './agentSessionsViewer.js'; +import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, IMarshalledAgentSessionContext, isAgentSession, isAgentSessionSection, isAgentSessionShowLess, isAgentSessionShowMore } from './agentSessionsModel.js'; +import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionSectionLabels, AgentSessionShowLessRenderer, AgentSessionShowMoreRenderer, AgentSessionsSorter, getRepositoryName, IAgentSessionsFilter } from './agentSessionsViewer.js'; import { AgentSessionsGrouping, AgentSessionsSorting } from './agentSessionsFilter.js'; import { AgentSessionApprovalModel } from './agentSessionApprovalModel.js'; import { FuzzyScore } from '../../../../../base/common/filters.js'; @@ -42,6 +42,7 @@ import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js'; import { IMouseEvent } from '../../../../../base/browser/mouseEvent.js'; import { IChatWidget } from '../chat.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; export interface IAgentSessionsControlOptions { readonly overrideStyles: IStyleOverride; @@ -50,6 +51,10 @@ export interface IAgentSessionsControlOptions { readonly disableHover?: boolean; readonly enableApprovalRow?: boolean; readonly repositoryGroupLimit?: number; + readonly hideSectionCount?: boolean; + readonly hideSessionBadge?: boolean; + readonly useStatusOnlyIcons?: boolean; + readonly compactShowMore?: boolean; getHoverPosition(): HoverPosition; trackActiveEditorSession(): boolean; @@ -113,6 +118,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo @ITelemetryService private readonly telemetryService: ITelemetryService, @IEditorService private readonly editorService: IEditorService, @IStorageService private readonly storageService: IStorageService, + @IAccessibilityService private readonly accessibilityService: IAccessibilityService, ) { super(); @@ -262,16 +268,18 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo isGroupedByRepository: () => this.options.filter.groupResults?.() === AgentSessionsGrouping.Repository, isSortedByUpdated: () => this.options.filter.sortResults?.() === AgentSessionsSorting.Updated, }, approvalModel, activeSessionResource)); + const compact = this.options.compactShowMore; const sessionDataSource = this.sessionsDataSource = this._register(new AgentSessionsDataSource(this.options.filter, sorter, this.options.repositoryGroupLimit)); const list = this.sessionsList = this._register(this.instantiationService.createInstance(WorkbenchCompressibleAsyncDataTree, 'AgentSessionsView', container, - new AgentSessionsListDelegate(approvalModel), + new AgentSessionsListDelegate(approvalModel, this.options.compactShowMore), new AgentSessionsCompressionDelegate(), [ sessionRenderer, - this.instantiationService.createInstance(AgentSessionSectionRenderer), - new AgentSessionShowMoreRenderer(), + this.instantiationService.createInstance(AgentSessionSectionRenderer, { hideSectionCount: this.options.hideSectionCount }), + new AgentSessionShowMoreRenderer({ compactLabel: this.options.compactShowMore }), + new AgentSessionShowLessRenderer(), ], sessionDataSource, { @@ -298,6 +306,208 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo } })); + // In compact mode, expand show-more/show-less when hovering any item in the same group + if (compact) { + let expandedShowMoreElement: AgentSessionListItem | undefined; + let expandedSectionLabel: string | undefined; + let currentAnimatedHeight = AgentSessionShowMoreRenderer.COLLAPSED_HEIGHT; + + const sectionToShowMore = new Map(); + + const rebuildSectionMap = () => { + sectionToShowMore.clear(); + try { + const rootNode = list.getNode(); + for (const sectionNode of rootNode.children) { + if (isAgentSessionSection(sectionNode.element)) { + const label = sectionNode.element.label; + for (const child of sectionNode.children) { + if (isAgentSessionShowMore(child.element) || isAgentSessionShowLess(child.element)) { + sectionToShowMore.set(label, child.element); + } + } + } + } + } catch { + // Tree may not be initialized yet + } + }; + + let expandAnimationId: number | undefined; + let collapseAnimationId: number | undefined; + const targetWindow = getWindow(container); + + // Cancel pending animations on dispose to avoid calling into a disposed tree + this._register({ + dispose: () => { + if (expandAnimationId) { targetWindow.cancelAnimationFrame(expandAnimationId); } + if (collapseAnimationId) { targetWindow.cancelAnimationFrame(collapseAnimationId); } + } + }); + + const animateHeight = (element: AgentSessionListItem, from: number, to: number, onComplete?: () => void) => { + // Respect prefers-reduced-motion + if (this.accessibilityService.isMotionReduced()) { + if (list.hasNode(element)) { + isUpdatingHeight = true; + try { + list.updateElementHeight(element, to); + } finally { + isUpdatingHeight = false; + } + currentAnimatedHeight = to; + } + onComplete?.(); + return undefined; + } + + const duration = 150; + const start = Date.now(); + const step = () => { + const elapsed = Date.now() - start; + const progress = Math.min(elapsed / duration, 1); + const eased = 1 - Math.pow(1 - progress, 2); + const height = Math.round(from + (to - from) * eased); + if (list.hasNode(element)) { + isUpdatingHeight = true; + try { + list.updateElementHeight(element, height); + } finally { + isUpdatingHeight = false; + } + currentAnimatedHeight = height; + } + if (progress < 1) { + return targetWindow.requestAnimationFrame(step); + } + onComplete?.(); + return undefined; + }; + return targetWindow.requestAnimationFrame(step); + }; + + const collapseCurrentShowMore = () => { + if (collapseAnimationId) { + targetWindow.cancelAnimationFrame(collapseAnimationId); + collapseAnimationId = undefined; + } + if (expandAnimationId) { + targetWindow.cancelAnimationFrame(expandAnimationId); + expandAnimationId = undefined; + } + if (expandedShowMoreElement && expandedSectionLabel) { + if (list.hasNode(expandedShowMoreElement)) { + collapseAnimationId = animateHeight( + expandedShowMoreElement, + currentAnimatedHeight, + AgentSessionShowMoreRenderer.COLLAPSED_HEIGHT, + () => { collapseAnimationId = undefined; } + ); + } + } + expandedShowMoreElement = undefined; + expandedSectionLabel = undefined; + }; + + const expandShowMore = (sectionLabel: string) => { + if (expandedSectionLabel === sectionLabel) { + return; + } + + collapseCurrentShowMore(); + + const showMoreItem = sectionToShowMore.get(sectionLabel); + if (!showMoreItem || !list.hasNode(showMoreItem)) { + return; + } + + expandedShowMoreElement = showMoreItem; + expandedSectionLabel = sectionLabel; + currentAnimatedHeight = AgentSessionShowMoreRenderer.COLLAPSED_HEIGHT; + expandAnimationId = animateHeight( + showMoreItem, + AgentSessionShowMoreRenderer.COLLAPSED_HEIGHT, + AgentSessionShowMoreRenderer.HEIGHT, + () => { expandAnimationId = undefined; } + ); + }; + + // Listen to tree model changes — rebuild the section map. + // Use a flag to avoid re-entrancy since updateElementHeight + // triggers model changes. + let isUpdatingHeight = false; + this._register(list.onDidChangeModel(() => { + if (isUpdatingHeight) { + return; + } + expandedShowMoreElement = undefined; + expandedSectionLabel = undefined; + currentAnimatedHeight = AgentSessionShowMoreRenderer.COLLAPSED_HEIGHT; + rebuildSectionMap(); + })); + + // On mouseover, determine section from the hovered element + this._register(addDisposableListener(container, 'mouseover', (e: MouseEvent) => { + const target = e.target as HTMLElement; + const row = target.closest('.monaco-list-row'); + if (!row) { + return; + } + + let sectionLabel: string | undefined; + + // Section header — querySelector is needed to identify elements within virtualized list rows + // eslint-disable-next-line no-restricted-syntax + const sectionHeaderEl = row.querySelector('.agent-session-section-label'); + if (sectionHeaderEl) { + sectionLabel = sectionHeaderEl.textContent ?? undefined; + } + + // Show-more element + if (!sectionLabel) { + // eslint-disable-next-line no-restricted-syntax + const showMoreEl = row.querySelector('.agent-session-show-more'); + if (showMoreEl) { + sectionLabel = showMoreEl.getAttribute('data-section-label') ?? undefined; + } + } + + // Session item — use data-section-label attribute + if (!sectionLabel) { + // eslint-disable-next-line no-restricted-syntax + const sessionItem = row.querySelector('.agent-session-item[data-section-label]'); + if (sessionItem) { + sectionLabel = sessionItem.getAttribute('data-section-label') ?? undefined; + } + } + + // If we couldn't determine the section but are still hovering + // inside a row with a session item, keep the current state + // (prevents collapse when hovering toolbar icons, diff stats, etc.) + if (!sectionLabel) { + // eslint-disable-next-line no-restricted-syntax + if (row.querySelector('.agent-session-item')) { + return; + } + collapseCurrentShowMore(); + return; + } + + if (!sectionToShowMore.has(sectionLabel)) { + collapseCurrentShowMore(); + return; + } + + expandShowMore(sectionLabel); + })); + + this._register(addDisposableListener(container, 'mouseleave', () => { + collapseCurrentShowMore(); + })); + + rebuildSectionMap(); + } + this._register(sessionDataSource.onDidGetChildren(count => { this.updateEmpty(count === 0); })); @@ -418,6 +628,11 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo return; } + if (isAgentSessionShowLess(element)) { + this.sessionsDataSource?.collapseRepositoryGroup(element.sectionLabel); + return; + } + this.telemetryService.publicLog2('agentSessionOpened', { providerType: element.providerType, source: this.options.source @@ -435,7 +650,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo } private async showContextMenu({ element, anchor, browserEvent }: ITreeContextMenuEvent): Promise { - if (!element || isAgentSessionShowMore(element)) { + if (!element || isAgentSessionShowMore(element) || isAgentSessionShowLess(element)) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index 05de1113bf4..76ee31512fe 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -219,6 +219,19 @@ export function isAgentSessionShowMore(obj: unknown): obj is IAgentSessionShowMo return (obj as IAgentSessionShowMore)?.showMore === true; } +/** + * A "Show less" item that appears as the last child + * of an expanded repository group section to allow collapsing back. + */ +export interface IAgentSessionShowLess { + readonly showLess: true; + readonly sectionLabel: string; +} + +export function isAgentSessionShowLess(obj: unknown): obj is IAgentSessionShowLess { + return (obj as IAgentSessionShowLess)?.showLess === true; +} + export interface IMarshalledAgentSessionContext { readonly $mid: MarshalledId.AgentSessionContext; @@ -358,6 +371,15 @@ class AgentSessionsLogger extends Disposable { } } + // Metadata + if (session.metadata && Object.keys(session.metadata).length > 0) { + lines.push(` Metadata:`); + for (const [key, value] of Object.entries(session.metadata)) { + const renderedValue = typeof value === 'string' ? value : safeStringify(value); + lines.push(` ${key}: ${renderedValue}`); + } + } + // Our state (read/unread, archived) lines.push(` State:`); lines.push(` Archived (provider): ${session.archived ?? 'N/A'}`); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 914abbc894e..8b0638c9a0a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -14,7 +14,7 @@ import { ICompressedTreeNode } from '../../../../../base/browser/ui/tree/compres import { ICompressibleKeyboardNavigationLabelProvider, ICompressibleTreeRenderer } from '../../../../../base/browser/ui/tree/objectTree.js'; import { ITreeNode, ITreeElementRenderDetails, IAsyncDataSource, ITreeSorter, ITreeDragAndDrop, ITreeDragOverReaction } from '../../../../../base/browser/ui/tree/tree.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; -import { AgentSessionSection, AgentSessionStatus, getAgentChangesSummary, hasValidDiff, IAgentSession, IAgentSessionSection, IAgentSessionShowMore, IAgentSessionsModel, isAgentSession, isAgentSessionSection, isAgentSessionShowMore, isAgentSessionsModel, isSessionInProgressStatus } from './agentSessionsModel.js'; +import { AgentSessionSection, AgentSessionStatus, getAgentChangesSummary, hasValidDiff, IAgentSession, IAgentSessionSection, IAgentSessionShowLess, IAgentSessionShowMore, IAgentSessionsModel, isAgentSession, isAgentSessionSection, isAgentSessionShowLess, isAgentSessionShowMore, isAgentSessionsModel, isSessionInProgressStatus } from './agentSessionsModel.js'; import { IconLabel } from '../../../../../base/browser/ui/iconLabel/iconLabel.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { Codicon } from '../../../../../base/common/codicons.js'; @@ -50,7 +50,7 @@ import { defaultButtonStyles } from '../../../../../platform/theme/browser/defau import { AgentSessionApprovalModel } from './agentSessionApprovalModel.js'; import { BugIndicatingError } from '../../../../../base/common/errors.js'; -export type AgentSessionListItem = IAgentSession | IAgentSessionSection | IAgentSessionShowMore; +export type AgentSessionListItem = IAgentSession | IAgentSessionSection | IAgentSessionShowMore | IAgentSessionShowLess; //#region Agent Session Renderer @@ -68,6 +68,7 @@ interface IAgentSessionItemTemplate { readonly titleToolbar: MenuWorkbenchToolBar; // Column 2 Row 2 + readonly detailsIcon: HTMLElement; readonly diffContainer: HTMLElement; readonly diffAddedSpan: HTMLSpanElement; readonly diffRemovedSpan: HTMLSpanElement; @@ -88,6 +89,8 @@ interface IAgentSessionItemTemplate { export interface IAgentSessionRendererOptions { readonly disableHover?: boolean; + readonly hideSessionBadge?: boolean; + readonly useStatusOnlyIcons?: boolean; getHoverPosition(): HoverPosition; isGroupedByRepository?(): boolean; @@ -144,6 +147,7 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre h('div.agent-session-title-toolbar@titleToolbar'), ]), h('div.agent-session-details-row', [ + h('div.agent-session-details-icon@detailsIcon'), h('div.agent-session-badge@badge'), h('span.agent-session-separator@separator'), h('div.agent-session-diff-container@diffContainer', @@ -178,6 +182,7 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre title: disposables.add(new IconLabel(elements.title, { supportHighlights: true, supportIcons: true })), pinnedIndicator: elements.pinnedIndicator, titleToolbar, + detailsIcon: elements.detailsIcon, badge: elements.badge, separator: elements.separator, diffContainer: elements.diffContainer, @@ -207,8 +212,27 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre // Archived template.element.classList.toggle('archived', session.element.isArchived()); - // Icon - template.icon.className = `agent-session-icon ${ThemeIcon.asClassName(this.getIcon(session.element))}${session.element.status === AgentSessionStatus.NeedsInput ? ' needs-input' : ''}`; + // Section label for group hover detection + if (this.options.isGroupedByRepository?.()) { + const repoName = getRepositoryName(session.element); + if (repoName) { + template.element.setAttribute('data-section-label', repoName); + } else { + template.element.removeAttribute('data-section-label'); + } + } else { + template.element.removeAttribute('data-section-label'); + } + + // Icon — in status-only mode, show status indicator in icon column and session type icon in details row + if (this.options.useStatusOnlyIcons) { + template.icon.className = `agent-session-icon ${ThemeIcon.asClassName(this.getIcon(session.element, true))}${session.element.status === AgentSessionStatus.NeedsInput ? ' needs-input' : ''}`; + template.detailsIcon.className = `agent-session-details-icon ${ThemeIcon.asClassName(session.element.icon)}`; + template.detailsIcon.classList.add('visible'); + } else { + template.icon.className = `agent-session-icon ${ThemeIcon.asClassName(this.getIcon(session.element))}${session.element.status === AgentSessionStatus.NeedsInput ? ' needs-input' : ''}`; + template.detailsIcon.className = 'agent-session-details-icon'; + } // Title const markdownTitle = new MarkdownString(session.element.label); @@ -279,6 +303,10 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre } private renderBadge(session: ITreeNode, template: IAgentSessionItemTemplate): boolean { + if (this.options.hideSessionBadge) { + return false; + } + const badge = session.element.badge; if (!badge) { return false; @@ -359,7 +387,7 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre return true; } - private getIcon(session: IAgentSession): ThemeIcon { + private getIcon(session: IAgentSession, statusOnly?: boolean): ThemeIcon { if (session.status === AgentSessionStatus.InProgress) { return Codicon.sessionInProgress; } @@ -372,15 +400,31 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre return Codicon.error; } + if (statusOnly) { + // PR status icons + const metadata = session.metadata; + const hasPR = metadata?.pullRequestUrl || metadata?.pullRequestNumber; + if (hasPR) { + if (metadata?.pullRequestMerged === true) { + return Codicon.gitMerge; + } + return Codicon.gitPullRequest; + } + } + if (!session.isRead() && !session.isArchived()) { return Codicon.circleFilled; } - if (session.providerType === AgentSessionProviders.Local) { + if (!statusOnly && session.providerType === AgentSessionProviders.Local) { return Codicon.circleSmallFilled; } - return session.icon; + if (!statusOnly) { + return session.icon; + } + + return Codicon.circleSmallFilled; } private renderDescription(session: ITreeNode, template: IAgentSessionItemTemplate): boolean { @@ -587,6 +631,10 @@ interface IAgentSessionSectionTemplate { readonly disposables: IDisposable; } +export interface IAgentSessionSectionRendererOptions { + readonly hideSectionCount?: boolean; +} + export class AgentSessionSectionRenderer implements ICompressibleTreeRenderer { static readonly TEMPLATE_ID = 'agent-session-section'; @@ -594,6 +642,7 @@ export class AgentSessionSectionRenderer implements ICompressibleTreeRenderer { static readonly TEMPLATE_ID = 'agent-session-show-more'; static readonly HEIGHT = 26; + static readonly COLLAPSED_HEIGHT = 1; readonly templateId = AgentSessionShowMoreRenderer.TEMPLATE_ID; + constructor(private readonly options?: IAgentSessionShowMoreRendererOptions) { } + renderTemplate(container: HTMLElement): IAgentSessionShowMoreTemplate { const disposables = new DisposableStore(); @@ -689,7 +749,10 @@ export class AgentSessionShowMoreRenderer implements ICompressibleTreeRenderer, _index: number, template: IAgentSessionShowMoreTemplate): void { - template.label.textContent = localize('agentSessions.showMore', "Show {0} More...", element.element.remainingCount); + template.label.textContent = this.options?.compactLabel + ? localize('agentSessions.showMoreCompact', "+{0} more", element.element.remainingCount) + : localize('agentSessions.showMore', "Show {0} More...", element.element.remainingCount); + template.container.setAttribute('data-section-label', element.element.sectionLabel); } renderCompressedElements(): void { @@ -703,6 +766,46 @@ export class AgentSessionShowMoreRenderer implements ICompressibleTreeRenderer { + + static readonly TEMPLATE_ID = 'agent-session-show-less'; + static readonly HEIGHT = AgentSessionShowMoreRenderer.HEIGHT; + + readonly templateId = AgentSessionShowLessRenderer.TEMPLATE_ID; + + renderTemplate(container: HTMLElement): IAgentSessionShowMoreTemplate { + const disposables = new DisposableStore(); + + const elements = h( + 'div.agent-session-show-more@container', + [h('span.agent-session-show-more-label@label')] + ); + + container.appendChild(elements.container); + + return { + container: elements.container, + label: elements.label, + disposables, + }; + } + + renderElement(element: ITreeNode, _index: number, template: IAgentSessionShowMoreTemplate): void { + template.label.textContent = localize('agentSessions.showLess', "Show less"); + template.container.setAttribute('data-section-label', element.element.sectionLabel); + } + + renderCompressedElements(): void { + throw new Error('Should never happen since show-less is incompressible'); + } + + disposeElement(): void { } + + disposeTemplate(templateData: IAgentSessionShowMoreTemplate): void { + templateData.disposables.dispose(); + } +} + //#endregion export class AgentSessionsListDelegate implements IListVirtualDelegate { @@ -710,15 +813,17 @@ export class AgentSessionsListDelegate implements IListVirtualDelegate this.repositoryGroupLimit) { - const visible = element.sessions.slice(0, this.repositoryGroupLimit); - const remainingCount = element.sessions.length - this.repositoryGroupLimit; - return [...visible, { showMore: true as const, sectionLabel: element.label, remainingCount }]; + if (isCappingEnabled && element.section === AgentSessionSection.Repository && element.sessions.length > this.repositoryGroupLimit) { + if (!this.expandedRepositoryGroups.has(element.label)) { + // Collapsed: show limited sessions + "show more" + const visible = element.sessions.slice(0, this.repositoryGroupLimit); + const remainingCount = element.sessions.length - this.repositoryGroupLimit; + return [...visible, { showMore: true as const, sectionLabel: element.label, remainingCount }]; + } else { + // Expanded: show all sessions + "show less" + return [...element.sessions, { showLess: true as const, sectionLabel: element.label }]; + } } return element.sessions; } @@ -1328,6 +1455,10 @@ export class AgentSessionsIdentityProvider implements IIdentityProvider c.toUpperCase()); + return name.replace(/\.md$/i, ''); } /** @@ -334,10 +334,18 @@ class AICustomizationItemRenderer implements IListRenderer { - const uriLabel = this.labelService.getUriLabel(element.uri, { relative: false }); - let content = `${element.name}\n${uriLabel}`; + let content: string; + if (element.isBuiltin) { + content = `${element.name}\n${localize('builtinSource', "Built-in")}`; + } else if (element.extensionLabel) { + content = `${element.name}\n${localize('fromExtension', "Extension: {0}", element.extensionLabel)}`; + } else { + const isWorkspaceItem = element.storage === PromptsStorage.local; + const uriLabel = this.labelService.getUriLabel(element.uri, { relative: isWorkspaceItem }); + content = `${element.name}\n${uriLabel}`; + } if (element.badgeTooltip) { content += `\n\n${element.badgeTooltip}`; } @@ -739,8 +747,8 @@ export class AICustomizationListWidget extends Disposable { const { secondary } = getContextMenuActions(actions, 'inline'); - // Add copy path actions - const copyActions = [ + // Add copy path actions (not shown for built-in items where the path is an implementation detail) + const copyActions = item.isBuiltin ? [] : [ new Separator(), new Action('copyFullPath', localize('copyFullPath', "Copy Full Path"), undefined, true, async () => { await this.clipboardService.writeText(item.uri.fsPath); @@ -1062,24 +1070,6 @@ export class AICustomizationListWidget extends Disposable { return !!chatExtensionId && ExtensionIdentifier.equals(extensionId, chatExtensionId); } - /** - * Resolves the display group key for an extension-storage item. - * Items from the default chat extension are re-grouped under "Built-in"; - * all other extension items keep their original storage as group key. - * - * Returns `undefined` when no override is needed (the item will fall back - * to its `storage` value for grouping). - * - * This is the single point where extension → group mapping is decided, - * making it easy to add dynamic filter layers in the future. - */ - private resolveExtensionGroupKey(extensionId: ExtensionIdentifier | undefined): string | undefined { - if (extensionId && this.isChatExtensionItem(extensionId)) { - return BUILTIN_STORAGE; - } - return undefined; - } - /** * Post-processes items to assign groupKey overrides for extension-sourced * items. Applies the built-in grouping consistently across all item types. @@ -1088,22 +1078,28 @@ export class AICustomizationListWidget extends Disposable { * agent hooks) are left untouched — groupKey overrides are only applied to * items whose current groupKey is `undefined`. */ - private applyBuiltinGroupKeys(items: IAICustomizationListItem[], extensionIdByUri: ReadonlyMap): void { - for (const item of items) { - if (item.groupKey !== undefined) { - continue; // respect explicit groupKey from upstream (e.g. instruction categories) - } + private applyBuiltinGroupKeys(items: IAICustomizationListItem[], extensionInfoByUri: ReadonlyMap): IAICustomizationListItem[] { + return items.map(item => { if (item.storage !== PromptsStorage.extension) { - continue; + return item; } - const extId = extensionIdByUri.get(item.uri.toString()); - const override = this.resolveExtensionGroupKey(extId); - if (override) { - // IAICustomizationListItem.groupKey is readonly for consumers but - // we own the items array here, so the mutation is safe. - (item as { groupKey?: string }).groupKey = override; + const extInfo = extensionInfoByUri.get(item.uri.toString()); + if (!extInfo) { + return item; } - } + const isBuiltin = this.isChatExtensionItem(extInfo.id); + if (isBuiltin) { + return { + ...item, + isBuiltin: true, + groupKey: item.groupKey ?? BUILTIN_STORAGE, + }; + } + return { + ...item, + extensionLabel: extInfo.displayName || extInfo.id.value, + }; + }); } /** @@ -1114,12 +1110,19 @@ export class AICustomizationListWidget extends Disposable { const promptType = sectionToPromptType(section); const items: IAICustomizationListItem[] = []; const disabledUris = this.promptsService.getDisabledPromptFiles(promptType); - const extensionIdByUri = new Map(); + const extensionInfoByUri = new Map(); if (promptType === PromptsType.agent) { // Use getCustomAgents which has parsed name/description from frontmatter const agents = await this.promptsService.getCustomAgents(CancellationToken.None); + // Build extension display name lookup from raw file list + const allAgentFiles = await this.promptsService.listPromptFiles(PromptsType.agent, CancellationToken.None); + for (const file of allAgentFiles) { + if (file.extension) { + extensionInfoByUri.set(file.uri.toString(), { id: file.extension.identifier, displayName: file.extension.displayName }); + } + } for (const agent of agents) { const filename = basename(agent.uri); items.push({ @@ -1133,9 +1136,9 @@ export class AICustomizationListWidget extends Disposable { pluginUri: agent.source.storage === PromptsStorage.plugin ? agent.source.pluginUri : undefined, disabled: disabledUris.has(agent.uri), }); - // Track extension ID for built-in grouping - if (agent.source.storage === PromptsStorage.extension) { - extensionIdByUri.set(agent.uri.toString(), agent.source.extensionId); + // Track extension ID for built-in grouping (if not already set from file list) + if (agent.source.storage === PromptsStorage.extension && !extensionInfoByUri.has(agent.uri.toString())) { + extensionInfoByUri.set(agent.uri.toString(), { id: agent.source.extensionId }); } } } else if (promptType === PromptsType.skill) { @@ -1145,7 +1148,7 @@ export class AICustomizationListWidget extends Disposable { const allSkillFiles = await this.promptsService.listPromptFiles(PromptsType.skill, CancellationToken.None); for (const file of allSkillFiles) { if (file.extension) { - extensionIdByUri.set(file.uri.toString(), file.extension.identifier); + extensionInfoByUri.set(file.uri.toString(), { id: file.extension.identifier, displayName: file.extension.displayName }); } } const seenUris = new ResourceSet(); @@ -1204,7 +1207,7 @@ export class AICustomizationListWidget extends Disposable { disabled: disabledUris.has(command.promptPath.uri), }); if (command.promptPath.extension) { - extensionIdByUri.set(command.promptPath.uri.toString(), command.promptPath.extension.identifier); + extensionInfoByUri.set(command.promptPath.uri.toString(), { id: command.promptPath.extension.identifier, displayName: command.promptPath.extension.displayName }); } } } else if (promptType === PromptsType.hook) { @@ -1314,7 +1317,7 @@ export class AICustomizationListWidget extends Disposable { const promptFiles = await this.promptsService.listPromptFiles(promptType, CancellationToken.None); for (const file of promptFiles) { if (file.extension) { - extensionIdByUri.set(file.uri.toString(), file.extension.identifier); + extensionInfoByUri.set(file.uri.toString(), { id: file.extension.identifier, displayName: file.extension.displayName }); } } const agentInstructionFiles = await this.promptsService.listAgentInstructions(CancellationToken.None, undefined); @@ -1410,11 +1413,11 @@ export class AICustomizationListWidget extends Disposable { // are re-grouped under "Built-in" instead of "Extensions". // This is a single-pass transformation applied after all items are // collected, keeping the item-building code free of grouping logic. - this.applyBuiltinGroupKeys(items, extensionIdByUri); + const groupedItems = this.applyBuiltinGroupKeys(items, extensionInfoByUri); // Apply storage source filter (removes items not in visible sources or excluded user roots) const filter = this.workspaceService.getStorageSourceFilter(promptType); - const filteredItems = applyStorageSourceFilter(items, filter); + const filteredItems = applyStorageSourceFilter(groupedItems, filter); items.length = 0; items.push(...filteredItems); @@ -1706,32 +1709,36 @@ export class AICustomizationListWidget extends Disposable { } } + /** + * Scrolls the list so the last item is visible. + */ + revealLastItem(): void { + if (this.displayEntries.length > 0) { + this.list.reveal(this.displayEntries.length - 1); + } + } + /** * Layouts the widget. */ layout(height: number, width: number): void { - const sectionFooterHeight = this.sectionHeader.offsetHeight || 0; - const searchBarHeight = this.searchAndButtonContainer.offsetHeight || 52; - const listHeight = height - sectionFooterHeight - searchBarHeight; - + this.element.style.height = `${height}px`; this.searchInput.layout(); - this.listContainer.style.height = `${Math.max(0, listHeight)}px`; - this.list.layout(Math.max(0, listHeight), width); - // Re-layout once after footer renders if we used a zero fallback - if (sectionFooterHeight === 0) { - DOM.getWindow(this.listContainer).requestAnimationFrame(() => { - if (this._store.isDisposed) { - return; - } - const actualFooterHeight = this.sectionHeader.offsetHeight; - if (actualFooterHeight > 0) { - const correctedHeight = height - actualFooterHeight - searchBarHeight; - this.listContainer.style.height = `${Math.max(0, correctedHeight)}px`; - this.list.layout(Math.max(0, correctedHeight), width); - } - }); + // Measure sibling elements to calculate the remaining space for the list. + // When offsetHeight returns 0 the container just became visible + // after display:none and the browser hasn't reflowed yet — defer + // layout to the next frame so measurements are accurate. + const searchBarHeight = this.searchAndButtonContainer.offsetHeight; + if (searchBarHeight === 0) { + DOM.getWindow(this.element).requestAnimationFrame(() => this.layout(height, width)); + return; } + const footerHeight = this.sectionHeader.offsetHeight; + const listHeight = Math.max(0, height - searchBarHeight - footerHeight); + + this.listContainer.style.height = `${listHeight}px`; + this.list.layout(listHeight, width); } /** diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 2896cbbef08..f5de7aec226 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -1281,6 +1281,11 @@ export class AICustomizationManagementEditor extends EditorPane { if (this.isPromptsSection(sectionId)) { void this.listWidget.setSection(sectionId); } + // Re-layout after visibility change so the newly-visible widget + // can measure its flex-computed container height correctly. + if (this.dimension) { + this.layout(this.dimension); + } this.ensureSectionsListReflectsActiveSection(sectionId); } } @@ -1292,6 +1297,19 @@ export class AICustomizationManagementEditor extends EditorPane { void this.listWidget.refresh(); } + /** + * Scrolls the active list widget so the last item is visible. + */ + public revealLastItem(): void { + if (this.selectedSection === AICustomizationManagementSection.McpServers) { + this.mcpListWidget?.revealLastItem(); + } else if (this.selectedSection === AICustomizationManagementSection.Plugins) { + this.pluginListWidget?.revealLastItem(); + } else { + this.listWidget.revealLastItem(); + } + } + /** * Generates a debug report for the current section. */ diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index a4b197e51e1..23fea69dcde 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -891,28 +891,24 @@ export class McpListWidget extends Disposable { layout(height: number, width: number): void { this.lastHeight = height; this.lastWidth = width; - const sectionFooterHeight = this.sectionHeader.offsetHeight || 0; - const searchBarHeight = this.searchAndButtonContainer.offsetHeight || 52; - const backLinkHeight = this.browseMode ? (this.backLink.offsetHeight || 28) : 0; - const listHeight = height - sectionFooterHeight - searchBarHeight - backLinkHeight; - this.listContainer.style.height = `${Math.max(0, listHeight)}px`; - this.list.layout(Math.max(0, listHeight), width); + this.element.style.height = `${height}px`; - // Re-layout once after footer renders if we used a zero fallback - if (sectionFooterHeight === 0) { - DOM.getWindow(this.listContainer).requestAnimationFrame(() => { - if (this._store.isDisposed) { - return; - } - const actualFooterHeight = this.sectionHeader.offsetHeight; - if (actualFooterHeight > 0) { - const correctedHeight = height - actualFooterHeight - searchBarHeight - backLinkHeight; - this.listContainer.style.height = `${Math.max(0, correctedHeight)}px`; - this.list.layout(Math.max(0, correctedHeight), width); - } - }); + // Measure sibling elements to calculate the list height. + // When offsetHeight returns 0 the container just became visible + // after display:none and the browser hasn't reflowed yet — defer + // layout to the next frame so measurements are accurate. + const searchBarHeight = this.searchAndButtonContainer.offsetHeight; + if (searchBarHeight === 0) { + DOM.getWindow(this.element).requestAnimationFrame(() => this.layout(this.lastHeight, this.lastWidth)); + return; } + const footerHeight = this.sectionHeader.offsetHeight; + const backLinkHeight = this.browseMode ? this.backLink.offsetHeight : 0; + const listHeight = Math.max(0, height - searchBarHeight - footerHeight - backLinkHeight); + + this.listContainer.style.height = `${listHeight}px`; + this.list.layout(listHeight, width); } /** @@ -922,6 +918,15 @@ export class McpListWidget extends Disposable { this.searchInput.focus(); } + /** + * Scrolls the list so the last item is visible. + */ + revealLastItem(): void { + if (this.list.length > 0) { + this.list.reveal(this.list.length - 1); + } + } + /** * Focuses the list. */ diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css index b4e17ae435e..4c6fd79b107 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -168,6 +168,7 @@ display: flex; flex-direction: column; height: 100%; + overflow: hidden; } /* Harness dropdown in sidebar */ @@ -719,6 +720,7 @@ display: flex; flex-direction: column; height: 100%; + overflow: hidden; } .mcp-list-widget .section-footer { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts index a51a9f6fb0e..28ec72c3c1f 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts @@ -710,33 +710,36 @@ export class PluginListWidget extends Disposable { layout(height: number, width: number): void { this.lastHeight = height; this.lastWidth = width; - const sectionFooterHeight = this.sectionHeader.offsetHeight || 0; - const searchBarHeight = this.searchAndButtonContainer.offsetHeight || 52; - const backLinkHeight = this.browseMode ? (this.backLink.offsetHeight || 28) : 0; - const listHeight = height - sectionFooterHeight - searchBarHeight - backLinkHeight; - this.listContainer.style.height = `${Math.max(0, listHeight)}px`; - this.list.layout(Math.max(0, listHeight), width); + this.element.style.height = `${height}px`; - if (sectionFooterHeight === 0) { - DOM.getWindow(this.listContainer).requestAnimationFrame(() => { - if (this._store.isDisposed) { - return; - } - const actualFooterHeight = this.sectionHeader.offsetHeight; - if (actualFooterHeight > 0) { - const correctedHeight = height - actualFooterHeight - searchBarHeight - backLinkHeight; - this.listContainer.style.height = `${Math.max(0, correctedHeight)}px`; - this.list.layout(Math.max(0, correctedHeight), width); - } - }); + // Measure sibling elements to calculate the list height. + // When offsetHeight returns 0 the container just became visible + // after display:none and the browser hasn't reflowed yet — defer + // layout to the next frame so measurements are accurate. + const searchBarHeight = this.searchAndButtonContainer.offsetHeight; + if (searchBarHeight === 0) { + DOM.getWindow(this.element).requestAnimationFrame(() => this.layout(this.lastHeight, this.lastWidth)); + return; } + const footerHeight = this.sectionHeader.offsetHeight; + const backLinkHeight = this.browseMode ? this.backLink.offsetHeight : 0; + const listHeight = Math.max(0, height - searchBarHeight - footerHeight - backLinkHeight); + + this.listContainer.style.height = `${listHeight}px`; + this.list.layout(listHeight, width); } focusSearch(): void { this.searchInput.focus(); } + revealLastItem(): void { + if (this.list.length > 0) { + this.list.reveal(this.list.length - 1); + } + } + focus(): void { this.list.domFocus(); if (this.list.length > 0) { diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 044678a421a..4304591308d 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -323,11 +323,7 @@ configurationRegistry.registerConfiguration({ type: 'boolean', scope: ConfigurationScope.APPLICATION, description: nls.localize('chat.tips.enabled', "Controls whether tips are shown above user messages in chat. New tips are added frequently, so this is a helpful way to stay up to date with the latest features."), - default: false, - tags: ['experimental'], - experiment: { - mode: 'auto' - } + default: true, }, 'chat.upvoteAnimation': { type: 'string', 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 868a46c8339..d96005ed591 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -1215,7 +1215,9 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } public async forkChatSession(sessionResource: URI, request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken): Promise { - const session = this._sessions.get(this._resolveResource(sessionResource)); + const session = this._sessions.get(sessionResource) + // Try to resolve in case an alias was used + ?? this._sessions.get(this._resolveResource(sessionResource)); if (!session?.session.forkSession) { throw new Error(`Session ${sessionResource.toString()} does not support forking`); } diff --git a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts index 652cf2d8d42..44fccdc7c0d 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts @@ -18,7 +18,7 @@ import { IProgressService, ProgressLocation } from '../../../../platform/progres import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; import { IAgentPluginRepositoryService } from '../common/plugins/agentPluginRepositoryService.js'; import { ChatConfiguration } from '../common/constants.js'; -import { IPluginInstallService, IUpdateAllPluginsOptions, IUpdateAllPluginsResult } from '../common/plugins/pluginInstallService.js'; +import { IPluginInstallService, IInstallPluginFromSourceOptions, IInstallPluginFromSourceResult, IUpdateAllPluginsOptions, IUpdateAllPluginsResult } from '../common/plugins/pluginInstallService.js'; import { IMarketplacePlugin, IMarketplaceReference, IPluginMarketplaceService, MarketplaceReferenceKind, MarketplaceType, hasSourceChanged, parseMarketplaceReference, parseMarketplaceReferences, PluginSourceKind } from '../common/plugins/pluginMarketplaceService.js'; export class PluginInstallService implements IPluginInstallService { @@ -57,7 +57,7 @@ export class PluginInstallService implements IPluginInstallService { return this._installGitPlugin(plugin); } - async installPluginFromSource(source: string): Promise { + async installPluginFromSource(source: string, options?: IInstallPluginFromSourceOptions): Promise { const reference = parseMarketplaceReference(source); if (!reference) { this._notificationService.notify({ @@ -75,7 +75,7 @@ export class PluginInstallService implements IPluginInstallService { return; } - const result = await this._doInstallFromSource(reference); + const result = await this._doInstallFromSource(reference, options); if (!result.success && result.message) { this._notificationService.notify({ severity: Severity.Error, @@ -95,7 +95,7 @@ export class PluginInstallService implements IPluginInstallService { return undefined; } - async installPluginFromValidatedSource(source: string): Promise<{ success: boolean; message?: string }> { + async installPluginFromValidatedSource(source: string, options?: IInstallPluginFromSourceOptions): Promise { const reference = parseMarketplaceReference(source); if (!reference) { return { @@ -110,10 +110,10 @@ export class PluginInstallService implements IPluginInstallService { }; } - return this._doInstallFromSource(reference); + return this._doInstallFromSource(reference, options); } - private async _doInstallFromSource(reference: IMarketplaceReference): Promise<{ success: boolean; message?: string }> { + private async _doInstallFromSource(reference: IMarketplaceReference, options?: IInstallPluginFromSourceOptions): Promise { // Build a source descriptor for the git clone. const sourceDescriptor = reference.kind === MarketplaceReferenceKind.GitHubShorthand ? { kind: PluginSourceKind.GitHub as const, repo: reference.githubRepo! } @@ -170,11 +170,23 @@ export class PluginInstallService implements IPluginInstallService { }; } + // When targeting a specific plugin, find it, register it, and return. + if (options?.plugin) { + const matchedPlugin = discoveredPlugins.find(p => p.name === options.plugin); + if (!matchedPlugin) { + return { + success: false, + message: localize('pluginNotFound', "Plugin '{0}' not found in '{1}'.", options.plugin, reference.displayLabel), + }; + } + await this._addMarketplaceToConfig(reference); + await this.installPlugin(matchedPlugin); + return { success: true, matchedPlugin }; + } + if (discoveredPlugins.length === 1) { - const plugin = discoveredPlugins[0]; - const pluginDir = plugin.source ? URI.joinPath(repoDir, plugin.source) : repoDir; - this._pluginMarketplaceService.addInstalledPlugin(pluginDir, plugin); - this._addMarketplaceToConfig(reference); + await this._addMarketplaceToConfig(reference); + await this.installPlugin(discoveredPlugins[0]); return { success: true }; } @@ -194,20 +206,19 @@ export class PluginInstallService implements IPluginInstallService { return { success: false }; } - const plugin = selected.plugin; - const pluginDir = plugin.source ? URI.joinPath(repoDir, plugin.source) : repoDir; - this._pluginMarketplaceService.addInstalledPlugin(pluginDir, plugin); - this._addMarketplaceToConfig(reference); + await this._addMarketplaceToConfig(reference); + await this.installPlugin(selected.plugin); + return { success: true }; } - private _addMarketplaceToConfig(reference: IMarketplaceReference): void { + private _addMarketplaceToConfig(reference: IMarketplaceReference) { const currentValues = this._configurationService.getValue(ChatConfiguration.PluginMarketplaces) ?? []; const existingRefs = parseMarketplaceReferences(currentValues); if (existingRefs.some(r => r.canonicalId === reference.canonicalId)) { return; } - this._configurationService.updateValue(ChatConfiguration.PluginMarketplaces, [...currentValues, reference.rawValue]); + return this._configurationService.updateValue(ChatConfiguration.PluginMarketplaces, [...currentValues, reference.rawValue]); } async updatePlugin(plugin: IMarketplacePlugin, silent?: boolean): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/pluginUrlHandler.ts b/src/vs/workbench/contrib/chat/browser/pluginUrlHandler.ts index d3a0ec3613b..5997044d851 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginUrlHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginUrlHandler.ts @@ -4,28 +4,35 @@ *--------------------------------------------------------------------------------------------*/ import { mainWindow } from '../../../../base/browser/window.js'; +import { decodeBase64 } from '../../../../base/common/buffer.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IURLHandler, IURLService } from '../../../../platform/url/common/url.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; import { IHostService } from '../../../services/host/browser/host.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { IExtensionsWorkbenchService } from '../../extensions/common/extensions.js'; +import { AgentPluginEditorInput } from './agentPluginEditor/agentPluginEditorInput.js'; +import { AgentPluginItemKind, IMarketplacePluginItem } from './agentPluginEditor/agentPluginItems.js'; import { ChatConfiguration } from '../common/constants.js'; import { MarketplaceReferenceKind, parseMarketplaceReference, parseMarketplaceReferences } from '../common/plugins/marketplaceReference.js'; import { IPluginInstallService } from '../common/plugins/pluginInstallService.js'; -import { decodeBase64 } from '../../../../base/common/buffer.js'; /** - * Handles `vscode://chat-plugin/install?source=` and + * Handles `vscode://chat-plugin/install?source=[&plugin=]` and * `vscode://chat-plugin/add-marketplace?ref=` URLs. * * The `source` / `ref` query parameter is a base64-encoded `owner/repo` or - * git clone URL. A confirmation dialog is always shown before any action. + * git clone URL. When `plugin` is provided on the `/install` route, the handler + * targets that specific plugin within the marketplace, installs it, and opens + * its details in the editor. Otherwise, a confirmation dialog is shown before + * any action. */ export class PluginUrlHandler extends Disposable implements IWorkbenchContribution, IURLHandler { @@ -39,6 +46,8 @@ export class PluginUrlHandler extends Disposable implements IWorkbenchContributi @IExtensionsWorkbenchService private readonly _extensionsWorkbenchService: IExtensionsWorkbenchService, @IHostService private readonly _hostService: IHostService, @ILogService private readonly _logService: ILogService, + @IEditorService private readonly _editorService: IEditorService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); this._register(urlService.registerHandler(this)); @@ -81,6 +90,11 @@ export class PluginUrlHandler extends Disposable implements IWorkbenchContributi await this._hostService.focus(mainWindow); + const pluginName = this._decodeStringParam(uri, 'plugin'); + if (pluginName) { + return this._handleInstallTargetedPlugin(source, ref.displayLabel, pluginName); + } + const { confirmed } = await this._dialogService.confirm({ type: 'question', message: localize('confirmInstallPlugin', "Install Plugin from '{0}'?", ref.displayLabel), @@ -98,6 +112,46 @@ export class PluginUrlHandler extends Disposable implements IWorkbenchContributi return true; } + /** + * Handles the case where a specific plugin is targeted within a + * marketplace. Delegates trust and discovery to the install service, + * then opens the plugin details in a modal editor. + */ + private async _handleInstallTargetedPlugin(source: string, displayLabel: string, pluginName: string): Promise { + const result = await this._pluginInstallService.installPluginFromValidatedSource(source, { plugin: pluginName }); + + if (!result.success) { + if (result.message) { + this._logService.warn(`[PluginUrlHandler] ${result.message}`); + } + this._extensionsWorkbenchService.openSearch(`@agentPlugins ${displayLabel}`); + return true; + } + + if (!result.matchedPlugin) { + this._extensionsWorkbenchService.openSearch(`@agentPlugins ${displayLabel}`); + return true; + } + + const plugin = result.matchedPlugin; + const item: IMarketplacePluginItem = { + kind: AgentPluginItemKind.Marketplace, + name: plugin.name, + description: plugin.description, + source: plugin.source, + sourceDescriptor: plugin.sourceDescriptor, + marketplace: plugin.marketplace, + marketplaceReference: plugin.marketplaceReference, + marketplaceType: plugin.marketplaceType, + readmeUri: plugin.readmeUri, + }; + + const input = this._instantiationService.createInstance(AgentPluginEditorInput, item); + await this._editorService.openEditor(input); + + return true; + } + // --- add a marketplace --- private async _handleAddMarketplace(uri: URI): Promise { @@ -155,16 +209,28 @@ export class PluginUrlHandler extends Disposable implements IWorkbenchContributi return undefined; } - // Try base64 first; if the decoded string is a valid reference, use it. + const decoded = this._tryBase64Decode(raw); + if (decoded && parseMarketplaceReference(decoded)) { + return decoded; + } + return parseMarketplaceReference(raw) ? raw : undefined; + } + + /** + * Reads a query parameter and decodes it. Tries base64-decoding first, + * then falls back to the raw value. + */ + private _decodeStringParam(uri: URI, key: string): string | undefined { + const params = new URLSearchParams(uri.query); + return params.get(key) ?? undefined; + } + + private _tryBase64Decode(raw: string): string | undefined { try { const decoded = decodeBase64(raw).toString(); - if (parseMarketplaceReference(decoded)) { - return decoded; - } + return decoded || undefined; } catch { - // not valid base64 + return undefined; } - - return raw; } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatArtifactsWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatArtifactsWidget.ts index 709ee187d2e..dadab4ff7aa 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatArtifactsWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatArtifactsWidget.ts @@ -38,6 +38,7 @@ export class ChatArtifactsWidget extends Disposable { private readonly _listStore = this._register(new DisposableStore()); private _expandIcon!: HTMLElement; private _titleElement!: HTMLElement; + private _clearButton!: Button; public static readonly ELEMENT_HEIGHT = 22; private static readonly MAX_ITEMS_SHOWN = 6; @@ -78,6 +79,19 @@ export class ChatArtifactsWidget extends Disposable { titleSection.appendChild(this._titleElement); headerButton.element.appendChild(titleSection); + // Add clear button container + const clearButtonContainer = dom.$('.artifacts-clear-button-container'); + this._clearButton = this._listStore.add(new Button(clearButtonContainer, { + supportIcons: true, + ariaLabel: localize('chat.artifacts.clearButton', 'Clear all artifacts'), + })); + this._clearButton.element.tabIndex = 0; + this._clearButton.icon = Codicon.clearAll; + this._listStore.add(this._clearButton.onDidClick(() => { + this._clearAllArtifacts(); + })); + headerButton.element.appendChild(clearButtonContainer); + this.domNode.appendChild(expandoContainer); const listContainer = dom.$('.chat-artifacts-list'); @@ -158,6 +172,13 @@ export class ChatArtifactsWidget extends Disposable { }); } + private _clearAllArtifacts(): void { + if (!this._sessionResource) { + return; + } + this._chatArtifactsService.setArtifacts(this._sessionResource, []); + } + hide(): void { this._autorunDisposable.clear(); this.domNode.style.display = 'none'; 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 8ec205d7f37..519e26c1b56 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -2251,6 +2251,42 @@ have to be updated for changes to the rules above, or to support more deeply nes font-size: 13px; } +.chat-artifacts-widget .artifacts-clear-button-container { + padding-right: 2px; + display: flex; + align-items: center; + height: 18px; + opacity: 1; +} + +.chat-artifacts-widget .artifacts-clear-button-container .monaco-button { + background-color: transparent; + border-color: transparent; + color: var(--vscode-foreground); + cursor: pointer; + height: 16px; + padding: 3px; + border-radius: 2px; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: unset; +} + +.chat-artifacts-widget .artifacts-clear-button-container .monaco-button:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.chat-artifacts-widget .artifacts-clear-button-container .monaco-button:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + +.chat-artifacts-widget .artifacts-clear-button-container .monaco-button .codicon { + font-size: 10px; + color: var(--vscode-foreground); +} + .interactive-session .checkpoint-file-changes-summary { display: flex; flex-direction: column; diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index 0c368744fe1..31db34d4dbc 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -163,10 +163,10 @@ export interface ICustomizationHarnessService { // #region Shared filter constants /** - * Hooks are always restricted to local + plugin sources regardless of harness. + * Hooks filter — local, user, and plugin sources. */ const HOOKS_FILTER: IStorageSourceFilter = { - sources: [PromptsStorage.local, PromptsStorage.plugin], + sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin], }; // #endregion diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts index 3691e142501..3174d30853e 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts @@ -33,6 +33,25 @@ export interface IUpdateAllPluginsResult { readonly failedNames: readonly string[]; } +export interface IInstallPluginFromSourceOptions { + /** + * When set, targets a specific plugin by name within the marketplace + * instead of installing all or prompting the user. The matched plugin + * is installed and returned in the result. + */ + readonly plugin?: string; +} + +export interface IInstallPluginFromSourceResult { + readonly success: boolean; + readonly message?: string; + /** + * When {@link IInstallPluginFromSourceOptions.plugin} is set and the + * plugin was found, this contains the discovered marketplace plugin. + */ + readonly matchedPlugin?: IMarketplacePlugin; +} + export interface IPluginInstallService { readonly _serviceBrand: undefined; @@ -47,8 +66,11 @@ export interface IPluginInstallService { * GitHub shorthand (`owner/repo`) or a full git clone URL. Clones the * repository, reads marketplace metadata to discover plugins, and * registers the selected plugin. + * + * When {@link IInstallPluginFromSourceOptions.plugin} is set, targets + * a specific plugin, installs it, and returns it. */ - installPluginFromSource(source: string): Promise; + installPluginFromSource(source: string, options?: IInstallPluginFromSourceOptions): Promise; /** * Synchronously validates the format of a plugin source string. @@ -60,8 +82,12 @@ export interface IPluginInstallService { * Installs a plugin from an already-validated source string. * Handles trust, cloning, scanning, and registration. Returns a result * with an optional error message (e.g. no plugins found). + * + * When {@link IInstallPluginFromSourceOptions.plugin} is set, targets + * a specific plugin, installs it, and returns it in + * {@link IInstallPluginFromSourceResult.matchedPlugin}. */ - installPluginFromValidatedSource(source: string): Promise<{ success: boolean; message?: string }>; + installPluginFromValidatedSource(source: string, options?: IInstallPluginFromSourceOptions): Promise; /** * Pulls the latest changes for an already-cloned marketplace repository. diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 707950cb152..28bd11ed040 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -1477,9 +1477,10 @@ export class PromptsService extends Disposable implements IPromptsService { this.logger.debug(`[computeSkillDiscoveryInfo] Agent skill file missing name attribute, using folder name "${folderName}": ${uri}`); name = folderName; } - const sanitizedName = this.truncateAgentSkillName(name, uri); + let sanitizedName = this.truncateAgentSkillName(name, uri); if (sanitizedName !== folderName) { this.logger.debug(`[computeSkillDiscoveryInfo] Agent skill name "${sanitizedName}" does not match folder name "${folderName}", using folder name: ${uri}`); + sanitizedName = folderName; } if (seenNames.has(sanitizedName)) { diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index af4de401d07..b74009f4a7d 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -319,7 +319,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { modeInstructions, parentRequestId: invocation.chatRequestId, hooks: collectedHooks, - hasHooksEnabled: !!collectedHooks && Object.values(collectedHooks).some(arr => arr.length > 0), + hasHooksEnabled: !!collectedHooks && Object.values(collectedHooks).some(arr => arr && arr.length > 0), }; // Subscribe to tool invocations to clear markdown parts when a tool is invoked diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts index 9177b5307f4..e1ae10f6250 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { AgentSessionsDataSource, AgentSessionListItem, IAgentSessionsFilter, sessionDateFromNow, getRepositoryName, AgentSessionsSorter, groupAgentSessionsByDate } from '../../../browser/agentSessions/agentSessionsViewer.js'; -import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, isAgentSessionSection, isAgentSessionShowMore } from '../../../browser/agentSessions/agentSessionsModel.js'; +import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, isAgentSessionSection, isAgentSessionShowLess, isAgentSessionShowMore } from '../../../browser/agentSessions/agentSessionsModel.js'; import { ChatSessionStatus } from '../../../common/chatSessionsService.js'; import { ITreeSorter } from '../../../../../../base/browser/ui/tree/tree.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; @@ -1025,7 +1025,7 @@ suite('AgentSessionsDataSource', () => { assert.ok(!children.some(isAgentSessionShowMore)); }); - test('expanding a group removes the cap', () => { + test('expanding a group removes the cap and appends show-less item', () => { const now = Date.now(); const sessions = Array.from({ length: 8 }, (_, i) => createMockSession({ id: `s${i}`, metadata: { repositoryNwo: 'owner/vscode' }, startTime: now - i * 1000 }) @@ -1039,8 +1039,11 @@ suite('AgentSessionsDataSource', () => { dataSource.expandRepositoryGroup('vscode'); const children = Array.from(dataSource.getChildren(section)); - assert.strictEqual(children.length, 8); + assert.strictEqual(children.length, 9); // 8 sessions + 1 show-less assert.ok(!children.some(isAgentSessionShowMore)); + const showLess = children[8]; + assert.ok(isAgentSessionShowLess(showLess)); + assert.strictEqual(showLess.sectionLabel, 'vscode'); }); test('does not cap non-repository sections', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginUrlHandler.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginUrlHandler.test.ts index b843bc619e5..2b0f32639f8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginUrlHandler.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginUrlHandler.test.ts @@ -10,14 +10,18 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { ConfigurationTarget, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IURLService } from '../../../../../../platform/url/common/url.js'; +import { IEditorService } from '../../../../../services/editor/common/editorService.js'; import { IHostService } from '../../../../../services/host/browser/host.js'; import { IExtensionsWorkbenchService } from '../../../../extensions/common/extensions.js'; +import { AgentPluginEditorInput } from '../../../browser/agentPluginEditor/agentPluginEditorInput.js'; import { PluginUrlHandler } from '../../../browser/pluginUrlHandler.js'; import { ChatConfiguration } from '../../../common/constants.js'; -import { IPluginInstallService } from '../../../common/plugins/pluginInstallService.js'; +import { IInstallPluginFromSourceOptions, IInstallPluginFromSourceResult, IPluginInstallService } from '../../../common/plugins/pluginInstallService.js'; +import { IMarketplacePlugin, MarketplaceReferenceKind, MarketplaceType, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js'; function toBase64(value: string): string { return encodeBase64(VSBuffer.fromString(value)); @@ -30,6 +34,9 @@ suite('PluginUrlHandler', () => { dialogConfirmResult: boolean; installedSources: string[]; configUpdates: { key: string; value: unknown; target: ConfigurationTarget }[]; + openedEditorInputs: AgentPluginEditorInput[]; + openSearchQueries: string[]; + installFromValidatedSourceResult: IInstallPluginFromSourceResult; } function createHandler(stateOverrides?: Partial): { handler: PluginUrlHandler; state: MockState } { @@ -37,6 +44,9 @@ suite('PluginUrlHandler', () => { dialogConfirmResult: true, installedSources: [], configUpdates: [], + openedEditorInputs: [], + openSearchQueries: [], + installFromValidatedSourceResult: { success: false }, ...stateOverrides, }; @@ -48,6 +58,7 @@ suite('PluginUrlHandler', () => { instantiationService.stub(IPluginInstallService, { installPluginFromSource: async (source: string) => { state.installedSources.push(source); }, + installPluginFromValidatedSource: async (_source: string, _options?: IInstallPluginFromSourceOptions) => state.installFromValidatedSourceResult, } as unknown as IPluginInstallService); instantiationService.stub(IDialogService, { @@ -70,9 +81,20 @@ suite('PluginUrlHandler', () => { } as unknown as IHostService); instantiationService.stub(IExtensionsWorkbenchService, { - openSearch: () => { }, + openSearch: (query: string) => { state.openSearchQueries.push(query); }, } as unknown as IExtensionsWorkbenchService); + instantiationService.stub(IEditorService, { + openEditor: async (input: AgentPluginEditorInput) => { + state.openedEditorInputs.push(input); + store.add(input); + return undefined; + }, + } as unknown as IEditorService); + + // IInstantiationService: delegate createInstance to the TestInstantiationService itself + instantiationService.stub(IInstantiationService, instantiationService); + instantiationService.stub(ILogService, new NullLogService()); const handler = store.add(instantiationService.createInstance(PluginUrlHandler)); @@ -209,4 +231,76 @@ suite('PluginUrlHandler', () => { assert.strictEqual(result, true); assert.strictEqual(state.configUpdates.length, 0); }); + + // --- install with plugin targeting --- + + function makeMarketplacePlugin(name: string, marketplace: string): IMarketplacePlugin { + const [owner, repo] = marketplace.split('/'); + const ref = { + kind: MarketplaceReferenceKind.GitHubShorthand as const, + rawValue: marketplace, + displayLabel: marketplace, + canonicalId: `github:${owner.toLowerCase()}/${repo.toLowerCase()}`, + cloneUrl: `https://github.com/${marketplace}.git`, + githubRepo: marketplace, + cacheSegments: ['github.com', owner, repo], + }; + return { + name, + description: `${name} description`, + version: '1.0.0', + source: name, + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: name }, + marketplace, + marketplaceReference: ref, + marketplaceType: MarketplaceType.OpenPlugin, + }; + } + + test('install with plugin param delegates to installPluginFromValidatedSource and opens editor', async () => { + const plugin = makeMarketplacePlugin('my-plugin', 'acme/plugins'); + const { handler, state } = createHandler({ + installFromValidatedSourceResult: { success: true, matchedPlugin: plugin }, + }); + const result = await handler.handleURL(uri('/install', 'source=acme/plugins&plugin=my-plugin')); + assert.strictEqual(result, true); + // Should not show the install confirmation dialog (trust handled by install service) + assert.deepStrictEqual(state.installedSources, []); + // Plugin editor was opened + assert.strictEqual(state.openedEditorInputs.length, 1); + assert.strictEqual(state.openedEditorInputs[0].item.name, 'my-plugin'); + }); + + test('install with base64-encoded plugin param opens editor', async () => { + const plugin = makeMarketplacePlugin('my-plugin', 'acme/plugins'); + const { handler, state } = createHandler({ + installFromValidatedSourceResult: { success: true, matchedPlugin: plugin }, + }); + const encodedPlugin = toBase64('my-plugin'); + const result = await handler.handleURL(uri('/install', `source=acme/plugins&plugin=${encodedPlugin}`)); + assert.strictEqual(result, true); + assert.strictEqual(state.openedEditorInputs.length, 1); + assert.strictEqual(state.openedEditorInputs[0].item.name, 'my-plugin'); + }); + + test('install with plugin param falls back to search on failure', async () => { + const { handler, state } = createHandler({ + installFromValidatedSourceResult: { success: false, message: 'Plugin not found' }, + }); + const result = await handler.handleURL(uri('/install', 'source=acme/plugins&plugin=nonexistent')); + assert.strictEqual(result, true); + assert.strictEqual(state.openedEditorInputs.length, 0); + assert.strictEqual(state.openSearchQueries.length, 1); + assert.ok(state.openSearchQueries[0].includes('acme/plugins')); + }); + + test('install with plugin param falls back to search when no matchedPlugin', async () => { + const { handler, state } = createHandler({ + installFromValidatedSourceResult: { success: true }, + }); + const result = await handler.handleURL(uri('/install', 'source=acme/plugins&plugin=my-plugin')); + assert.strictEqual(result, true); + assert.strictEqual(state.openedEditorInputs.length, 0); + assert.strictEqual(state.openSearchQueries.length, 1); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts index f78133dcdba..9cf3150e5ae 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts @@ -1532,7 +1532,7 @@ suite('ComputeAutomaticInstructions', () => { assert.equal(xmlContents(skills[1], 'file')[0], getFilePath(`${rootFolder}/.claude/skills/no-desc-skill/SKILL.md`)); // Skill with mismatched name should use folder name - assert.equal(xmlContents(skills[2], 'name')[0], 'mismatched-name'); + assert.equal(xmlContents(skills[2], 'name')[0], 'actual-folder'); assert.equal(xmlContents(skills[2], 'description')[0], 'A skill with mismatched name'); assert.equal(xmlContents(skills[2], 'file')[0], getFilePath(`${rootFolder}/.claude/skills/actual-folder/SKILL.md`)); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 1f4a09bd0d3..cd90c0c7e7f 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -2789,7 +2789,7 @@ suite('PromptsService', () => { const result = allResult.filter(s => s.storage !== PromptsStorage.internal); assert.strictEqual(result.length, 2, 'Should find both skills'); - const mismatchedSkill = result.find(s => s.name === 'Correct Skill Name'); + const mismatchedSkill = result.find(s => s.name === 'wrong-folder-name'); assert.ok(mismatchedSkill, 'Should find skill with folder name as fallback'); assert.strictEqual(mismatchedSkill.description, 'This skill should use folder name as fallback'); diff --git a/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts b/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts index 5c7fbd966fb..967dd0feffd 100644 --- a/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts +++ b/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts @@ -159,11 +159,11 @@ registerAction2(OpenImageInCarouselAction); // --- Explorer Context Menu Integration --- -/** Supported image extensions for the carousel explorer context menu. */ -const IMAGE_EXTENSION_REGEX = /^\.(png|jpg|jpeg|jpe|gif|webp|svg|bmp|ico)$/i; +/** Supported media (image + video) extensions for the carousel explorer context menu. */ +const MEDIA_EXTENSION_REGEX = /^\.(png|jpg|jpeg|jpe|gif|webp|svg|bmp|ico|mp4|webm)$/i; -function isImageResource(uri: URI): boolean { - return IMAGE_EXTENSION_REGEX.test(extname(uri)); +function isMediaResource(uri: URI): boolean { + return MEDIA_EXTENSION_REGEX.test(extname(uri)); } async function collectImageFilesFromFolder(fileService: IFileService, folderUri: URI): Promise { @@ -171,7 +171,7 @@ async function collectImageFilesFromFolder(fileService: IFileService, folderUri: const imageUris: URI[] = []; if (stat.children) { for (const child of stat.children) { - if (child.isFile && isImageResource(child.resource)) { + if (child.isFile && isMediaResource(child.resource)) { imageUris.push(child.resource); } } @@ -203,7 +203,7 @@ class OpenImagesInCarouselFromExplorerAction extends Action2 { ContextKeyExpr.has('config.imageCarousel.explorerContextMenu.enabled'), ContextKeyExpr.or( ExplorerFolderContext, - ContextKeyExpr.regex(ResourceContextKey.Extension.key, IMAGE_EXTENSION_REGEX), + ContextKeyExpr.regex(ResourceContextKey.Extension.key, MEDIA_EXTENSION_REGEX), ), ), }], @@ -241,7 +241,7 @@ class OpenImagesInCarouselFromExplorerAction extends Action2 { imageUris = await collectImageFilesFromFolder(fileService, folderUri); } } else { - const hasSingleImageFile = context.length === 1 && !context[0].isDirectory && isImageResource(context[0].resource); + const hasSingleImageFile = context.length === 1 && !context[0].isDirectory && isMediaResource(context[0].resource); if (hasSingleImageFile) { // Single image: show all sibling images in the same folder with @@ -262,7 +262,7 @@ class OpenImagesInCarouselFromExplorerAction extends Action2 { imageUris.push(uri); } } - } else if (isImageResource(item.resource)) { + } else if (isMediaResource(item.resource)) { if (!seen.has(item.resource)) { seen.add(item.resource); imageUris.push(item.resource); diff --git a/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselEditor.ts b/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselEditor.ts index 06274338eec..611656daca9 100644 --- a/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselEditor.ts +++ b/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselEditor.ts @@ -10,6 +10,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { clamp } from '../../../../base/common/numbers.js'; import { isMacintosh } from '../../../../base/common/platform.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; import { IFileService } from '../../../../platform/files/common/files.js'; @@ -19,8 +20,9 @@ import { IEditorOpenContext } from '../../../common/editor.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; +import { IWebviewElement, IWebviewService } from '../../webview/browser/webview.js'; import { ImageCarouselEditorInput } from './imageCarouselEditorInput.js'; -import { ICarouselImage, ICarouselSection } from './imageCarouselTypes.js'; +import { ICarouselImage, ICarouselSection, isVideoMimeType } from './imageCarouselTypes.js'; /** * A flat entry referencing a specific image within a section, used @@ -52,11 +54,13 @@ export class ImageCarouselEditor extends EditorPane { private readonly _imageDisposables = this._register(new DisposableStore()); private readonly _blobUrlCache = new Map(); + private _videoWebview: IWebviewElement | undefined; private _elements: { root: HTMLElement; imageArea: HTMLElement; mainImageContainer: HTMLElement; mainImage: HTMLImageElement; + videoContainer: HTMLElement; captionText: HTMLElement; captionSeparator: HTMLElement; counter: HTMLElement; @@ -71,7 +75,8 @@ export class ImageCarouselEditor extends EditorPane { @ITelemetryService telemetryService: ITelemetryService, @IThemeService themeService: IThemeService, @IStorageService storageService: IStorageService, - @IFileService private readonly _fileService: IFileService + @IFileService private readonly _fileService: IFileService, + @IWebviewService private readonly _webviewService: IWebviewService ) { super(ImageCarouselEditor.ID, group, telemetryService, themeService, storageService); } @@ -96,6 +101,8 @@ export class ImageCarouselEditor extends EditorPane { } override clearInput(): void { + this._videoWebview?.dispose(); + this._videoWebview = undefined; this._contentDisposables.clear(); this._imageDisposables.clear(); this._revokeCachedBlobUrls(); @@ -108,6 +115,11 @@ export class ImageCarouselEditor extends EditorPane { super.clearInput(); } + private _isCurrentVideo(): boolean { + const entry = this._flatImages[this._currentIndex]; + return !!entry && isVideoMimeType(entry.image.mimeType); + } + /** * Build the full DOM skeleton. Called once per setInput. */ @@ -132,6 +144,7 @@ export class ImageCarouselEditor extends EditorPane { h('div.image-area@imageArea', [ h('div.main-image-container@mainImageContainer', [ h('img.main-image@mainImage'), + h('div.video-container@videoContainer'), ]), h('button.nav-arrow.prev-arrow@prevBtn', { ariaLabel: localize('imageCarousel.previousImage', "Previous image") }, [ h('span.codicon.codicon-chevron-left'), @@ -155,6 +168,7 @@ export class ImageCarouselEditor extends EditorPane { imageArea: elements.imageArea, mainImageContainer: elements.mainImageContainer, mainImage: elements.mainImage as HTMLImageElement, + videoContainer: elements.videoContainer, captionText: elements.captionText, captionSeparator: elements.captionSeparator, counter: elements.counter, @@ -166,6 +180,9 @@ export class ImageCarouselEditor extends EditorPane { // Initialize image in fit mode this._elements.mainImage.classList.add('scale-to-fit'); + // Hide video container initially + this._elements.videoContainer.style.display = 'none'; + // Navigation listeners this._contentDisposables.add(addDisposableListener(this._elements.prevBtn, 'click', () => { if (this._currentIndex > 0) { @@ -197,6 +214,9 @@ export class ImageCarouselEditor extends EditorPane { // Zoom: scroll wheel + modifier key (Ctrl on Win/Linux, Alt on Mac) or pinch this._contentDisposables.add(addDisposableListener(this._elements.imageArea, EventType.MOUSE_WHEEL, (e: WheelEvent) => { + if (this._isCurrentVideo()) { + return; + } const isZoomModifier = isMacintosh ? e.altKey : e.ctrlKey; if (!isZoomModifier && !e.ctrlKey) { return; @@ -227,7 +247,7 @@ export class ImageCarouselEditor extends EditorPane { clickAltPressed = e.altKey; })); this._contentDisposables.add(addDisposableListener(this._elements.mainImageContainer, EventType.CLICK, (e: MouseEvent) => { - if (e.button !== 0) { + if (e.button !== 0 || this._isCurrentVideo()) { return; } const isZoomOut = isMacintosh ? clickAltPressed : clickCtrlPressed; @@ -260,16 +280,32 @@ export class ImageCarouselEditor extends EditorPane { for (let i = 0; i < section.images.length; i++) { const image = section.images[i]; const currentFlatIndex = flatIndex; - const thumbnail = h('button.thumbnail@root', [ - h('img.thumbnail-image@img'), - ]); + const isItemVideo = isVideoMimeType(image.mimeType); - const btn = thumbnail.root as HTMLButtonElement; - btn.ariaLabel = localize('imageCarousel.thumbnailLabel', "Image {0} of {1}", currentFlatIndex + 1, this._flatImages.length); + const btn = document.createElement('button'); + btn.className = isItemVideo ? 'thumbnail video-thumbnail' : 'thumbnail'; + btn.ariaLabel = isItemVideo + ? localize('imageCarousel.thumbnailLabelVideo', "Video {0} of {1}", currentFlatIndex + 1, this._flatImages.length) + : localize('imageCarousel.thumbnailLabelImage', "Image {0} of {1}", currentFlatIndex + 1, this._flatImages.length); - const img = thumbnail.img as HTMLImageElement; - this._loadBlobUrl(image).then(url => { img.src = url; }); - img.alt = image.name; + if (isItemVideo) { + const icon = h('span.codicon.codicon-play.thumbnail-play-icon'); + icon.root.setAttribute('aria-hidden', 'true'); + btn.appendChild(icon.root); + } else { + const img = document.createElement('img'); + img.className = 'thumbnail-image'; + this._loadBlobUrl(image).then(url => { + img.src = url; + }, () => { + btn.classList.add('broken'); + }); + img.alt = image.name; + this._contentDisposables.add(addDisposableListener(img, 'error', () => { + btn.classList.add('broken'); + })); + btn.appendChild(img); + } this._contentDisposables.add(addDisposableListener(btn, 'click', () => { this._currentIndex = currentFlatIndex; @@ -306,29 +342,85 @@ export class ImageCarouselEditor extends EditorPane { // decodes on a worker thread, avoiding main-thread stalls during commit. const entry = this._flatImages[navigationIndex]; const currentImage = entry.image; - const url = await this._loadBlobUrl(currentImage); + const isVideo = isVideoMimeType(currentImage.mimeType); - // If the user navigated while loading the blob URL, discard this result. - if (this._currentIndex !== navigationIndex) { - return; + if (isVideo) { + // Show video container, hide image + this._elements.mainImage.style.display = 'none'; + this._elements.videoContainer.style.display = ''; + this._elements.mainImageContainer.classList.remove('zoomed'); + this._elements.mainImageContainer.style.cursor = 'default'; + + // Load raw data to send via postMessage + const rawData = await this._loadRawData(currentImage); + if (this._currentIndex !== navigationIndex) { + return; + } + + const nonce = generateUuid(); + const videoHtml = ` + + + + + + + +`; + + // Reuse existing webview or create one on first video navigation + let webview: IWebviewElement; + if (!this._videoWebview) { + webview = this._contentDisposables.add(this._webviewService.createWebviewElement({ + title: currentImage.name, + options: { disableServiceWorker: true }, + contentOptions: { allowScripts: true }, + extension: undefined, + })); + webview.mountTo(this._elements.videoContainer, this.window); + this._videoWebview = webview; + } else { + webview = this._videoWebview; + } + + webview.setHtml(videoHtml); + + // Send the video data to the webview via postMessage + const buffer = (rawData as Uint8Array).buffer; + webview.postMessage({ type: 'loadVideo', data: buffer, mimeType: currentImage.mimeType }, [buffer]); + } else { + // Show image, hide video container + this._elements.videoContainer.style.display = 'none'; + this._elements.mainImage.style.display = ''; + this._elements.mainImageContainer.style.cursor = ''; + + const url = await this._loadBlobUrl(currentImage); + + // If the user navigated while loading the blob URL, discard this result. + if (this._currentIndex !== navigationIndex) { + return; + } + + const tmp = new Image(); + tmp.src = url; + tmp.decode().then(() => { + // Only apply if user hasn't navigated away during decode + if (this._currentIndex === navigationIndex && this._elements) { + this._elements.mainImage.src = url; + this._elements.mainImage.alt = currentImage.name; + } + }, () => { + // Decode failed (invalid image) — still show src for browser fallback + if (this._currentIndex === navigationIndex && this._elements) { + this._elements.mainImage.src = url; + this._elements.mainImage.alt = currentImage.name; + } + }); } - const tmp = new Image(); - tmp.src = url; - tmp.decode().then(() => { - // Only apply if user hasn't navigated away during decode - if (this._currentIndex === navigationIndex && this._elements) { - this._elements.mainImage.src = url; - this._elements.mainImage.alt = currentImage.name; - } - }, () => { - // Decode failed (invalid image) — still show src for browser fallback - if (this._currentIndex === navigationIndex && this._elements) { - this._elements.mainImage.src = url; - this._elements.mainImage.alt = currentImage.name; - } - }); - // Reset zoom when switching images this._applyZoom('fit'); @@ -411,16 +503,32 @@ export class ImageCarouselEditor extends EditorPane { this._blobUrlCache.clear(); } + private async _loadRawData(image: ICarouselImage): Promise { + if (image.data) { + return image.data instanceof Uint8Array ? image.data : image.data.buffer; + } else if (image.uri) { + const content = await this._fileService.readFile(image.uri); + return content.value.buffer; + } + return new Uint8Array(0); + } + private _preloadAdjacentImages(): void { for (const idx of [this._currentIndex - 1, this._currentIndex + 1]) { if (idx >= 0 && idx < this._flatImages.length) { - this._loadBlobUrl(this._flatImages[idx].image).then(url => { - // Pre-decode via decode() so the compositor doesn't block - // the main thread decoding this image during commit. - const img = new Image(); - img.src = url; - img.decode().catch(() => { /* invalid image */ }); - }); + const adjacentImage = this._flatImages[idx].image; + if (isVideoMimeType(adjacentImage.mimeType)) { + // For video, preload raw data into the file service cache + this._loadRawData(adjacentImage).catch(() => { /* ignore */ }); + } else { + this._loadBlobUrl(adjacentImage).then(url => { + // Pre-decode via decode() so the compositor doesn't block + // the main thread decoding this image during commit. + const img = new Image(); + img.src = url; + img.decode().catch(() => { /* invalid image */ }); + }); + } } } } diff --git a/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselTypes.ts b/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselTypes.ts index c0aaa014ffd..0c713e948ad 100644 --- a/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselTypes.ts +++ b/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselTypes.ts @@ -27,3 +27,7 @@ export interface IImageCarouselCollection { readonly title: string; readonly sections: ReadonlyArray; } + +export function isVideoMimeType(mimeType: string): boolean { + return mimeType.startsWith('video/'); +} diff --git a/src/vs/workbench/contrib/imageCarousel/browser/media/imageCarousel.css b/src/vs/workbench/contrib/imageCarousel/browser/media/imageCarousel.css index 35926b8e7b2..4ef892836f8 100644 --- a/src/vs/workbench/contrib/imageCarousel/browser/media/imageCarousel.css +++ b/src/vs/workbench/contrib/imageCarousel/browser/media/imageCarousel.css @@ -84,6 +84,13 @@ margin: auto; } +.image-carousel-editor .video-container { + width: 100%; + height: 100%; + border-radius: 4px; + overflow: hidden; +} + .image-carousel-editor .main-image.scale-to-fit { max-width: 100%; max-height: 100%; @@ -239,8 +246,40 @@ border-color: var(--vscode-focusBorder); } +.image-carousel-editor .thumbnail.broken .thumbnail-image { + display: none; +} + +.image-carousel-editor .thumbnail.broken { + display: flex; + align-items: center; + justify-content: center; + background: var(--vscode-editor-background); +} + +.image-carousel-editor .thumbnail.broken::after { + font-family: codicon; + content: '\eaea'; /* file-media */ + font-size: 20px; + color: var(--vscode-descriptionForeground); +} + .image-carousel-editor .thumbnail-image { width: 100%; height: 100%; object-fit: cover; + pointer-events: none; +} + +/* Video thumbnail: play icon centered in a dark background */ +.image-carousel-editor .thumbnail.video-thumbnail { + display: flex; + align-items: center; + justify-content: center; + background: var(--vscode-editor-background); +} + +.image-carousel-editor .thumbnail-play-icon { + font-size: 20px; + color: var(--vscode-descriptionForeground); } diff --git a/src/vs/workbench/contrib/imageCarousel/test/browser/imageCarousel.contribution.test.ts b/src/vs/workbench/contrib/imageCarousel/test/browser/imageCarousel.contribution.test.ts index c78c14c5df0..37b32ac3077 100644 --- a/src/vs/workbench/contrib/imageCarousel/test/browser/imageCarousel.contribution.test.ts +++ b/src/vs/workbench/contrib/imageCarousel/test/browser/imageCarousel.contribution.test.ts @@ -396,4 +396,69 @@ suite('OpenImagesInCarouselFromExplorerAction', () => { assert.strictEqual(images[0].data, undefined, 'Image data should not be loaded eagerly'); assert.ok(images[0].uri, 'Image should have a URI for lazy loading'); }); + + test('folder includes video files alongside images', async () => { + const fileService = instantiationService.get(IFileService); + const folderItem = createExplorerItem('/workspace/media', true, fileService, configService); + + const resolveMap = new Map(); + resolveMap.set('/workspace/media', createFileStat( + URI.file('/workspace/media'), false, false, true, false, [ + { resource: URI.file('/workspace/media/clip.mp4'), isFile: true }, + { resource: URI.file('/workspace/media/photo.png'), isFile: true }, + { resource: URI.file('/workspace/media/demo.webm'), isFile: true }, + { resource: URI.file('/workspace/media/readme.txt'), isFile: true }, + ] + )); + + stubFileService(resolveMap, new Map()); + stubExplorerService([folderItem]); + stubEditorService(); + + const { CommandsRegistry } = await import('../../../../../platform/commands/common/commands.js'); + const command = CommandsRegistry.getCommand('workbench.action.openImagesInCarousel'); + assert.ok(command); + + await instantiationService.invokeFunction(command.handler); + + assert.strictEqual(openedInputs.length, 1); + const images = openedInputs[0].input.collection.sections[0].images; + assert.strictEqual(images.length, 3, 'Should include mp4 + png + webm, not txt'); + assert.strictEqual(images[0].name, 'clip.mp4'); + assert.strictEqual(images[1].name, 'demo.webm'); + assert.strictEqual(images[2].name, 'photo.png'); + }); + + test('single video file opens carousel with sibling media', async () => { + const fileService = instantiationService.get(IFileService); + const parent = createExplorerItem('/workspace/media', true, fileService, configService); + const videoItem = createExplorerItem('/workspace/media/clip.mp4', false, fileService, configService, parent); + + const resolveMap = new Map(); + resolveMap.set('/workspace/media', createFileStat( + URI.file('/workspace/media'), false, false, true, false, [ + { resource: URI.file('/workspace/media/clip.mp4'), isFile: true }, + { resource: URI.file('/workspace/media/photo.png'), isFile: true }, + { resource: URI.file('/workspace/media/notes.txt'), isFile: true }, + ] + )); + + stubFileService(resolveMap, new Map()); + stubExplorerService([videoItem]); + stubEditorService(); + + const { CommandsRegistry } = await import('../../../../../platform/commands/common/commands.js'); + const command = CommandsRegistry.getCommand('workbench.action.openImagesInCarousel'); + assert.ok(command); + + await instantiationService.invokeFunction(command.handler); + + assert.strictEqual(openedInputs.length, 1); + const input = openedInputs[0].input; + const images = input.collection.sections[0].images; + assert.strictEqual(images.length, 2, 'Should include mp4 + png siblings'); + assert.strictEqual(images[0].name, 'clip.mp4'); + assert.strictEqual(images[1].name, 'photo.png'); + assert.strictEqual(input.startIndex, 0, 'Start index should point to the selected video'); + }); }); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts index 3bcfdd27cb7..8a348c9fba5 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts @@ -67,7 +67,7 @@ export async function moveToPanelChat(accessor: ServicesAccessor, model: IChatMo } } -export async function askInPanelChat(accessor: ServicesAccessor, request: IChatRequestModel, state: IChatModelInputState | undefined) { +export async function askInPanelChat(accessor: ServicesAccessor, request: IChatRequestModel, state: IChatModelInputState | undefined, fileContext?: { uri: URI; selection: Selection }) { const widgetService = accessor.get(IChatWidgetService); const chatService = accessor.get(IChatService); @@ -88,6 +88,9 @@ export async function askInPanelChat(accessor: ServicesAccessor, request: IChatR const widget = await widgetService.openSession(newModelRef.object.sessionResource); newModelRef.dispose(); // can be freed after opening because the widget also holds a reference + if (widget && fileContext && !fileContext.selection.isEmpty()) { + await widget.attachmentModel.addFile(fileContext.uri, fileContext.selection); + } widget?.acceptInput(request.message.text); } @@ -97,7 +100,7 @@ export async function continueInPanelChat(accessor: ServicesAccessor, session: I return; } - await askInPanelChat(accessor, request, session.chatModel.inputModel.state.get()); + await askInPanelChat(accessor, request, session.chatModel.inputModel.state.get(), { uri: session.uri, selection: session.initialSelection }); session.dispose(); } diff --git a/src/vs/workbench/contrib/mergeEditor/browser/model/textModelDiffs.ts b/src/vs/workbench/contrib/mergeEditor/browser/model/textModelDiffs.ts index 2bd6be05fdc..18413330237 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/model/textModelDiffs.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/model/textModelDiffs.ts @@ -126,31 +126,43 @@ export class TextModelDiffs extends Disposable { this.ensureUpToDate(); diffToRemoves.sort(compareBy((d) => d.inputRange.startLineNumber, numberComparator)); - diffToRemoves.reverse(); + diffToRemoves.reverse(); // process from bottom of document upward - let diffs = this._diffs.get(); + const diffs = this._diffs.get(); - for (const diffToRemove of diffToRemoves) { - // TODO improve performance - const len = diffs.length; - diffs = diffs.filter((d) => d !== diffToRemove); - if (len === diffs.length) { + // Validate all diffs-to-remove exist using Set for O(1) lookup + const toRemoveSet = new Set(diffToRemoves); + if (toRemoveSet.size !== diffToRemoves.length) { + throw new BugIndicatingError(); // duplicate entries + } + const diffsSet = new Set(diffs); + for (const d of diffToRemoves) { + if (!diffsSet.has(d)) { throw new BugIndicatingError(); } + } + // Apply text model edits in reverse document order (bottom-up, safe for line shifting) + for (const diffToRemove of diffToRemoves) { this._barrier.runExclusivelyOrThrow(() => { const edits = diffToRemove.getReverseLineEdit().toEdits(this.textModel.getLineCount()); this.textModel.pushEditOperations(null, edits, () => null, group); }); - - diffs = diffs.map((d) => - d.outputRange.isAfter(diffToRemove.outputRange) - ? d.addOutputLineDelta(diffToRemove.inputRange.length - diffToRemove.outputRange.length) - : d - ); } - this._diffs.set(diffs, transaction, TextModelDiffChangeReason.other); + // Single forward pass: accumulate delta from removed diffs above, apply to remaining diffs below + let cumulativeDelta = 0; + const newDiffs: DetailedLineRangeMapping[] = []; + + for (const d of diffs) { + if (toRemoveSet.has(d)) { + cumulativeDelta += d.inputRange.length - d.outputRange.length; + } else { + newDiffs.push(cumulativeDelta !== 0 ? d.addOutputLineDelta(cumulativeDelta) : d); + } + } + + this._diffs.set(newDiffs, transaction, TextModelDiffChangeReason.other); } /** diff --git a/src/vs/workbench/contrib/performance/browser/perfviewEditor.ts b/src/vs/workbench/contrib/performance/browser/perfviewEditor.ts index b56d0efeeb9..e7202fc4764 100644 --- a/src/vs/workbench/contrib/performance/browser/perfviewEditor.ts +++ b/src/vs/workbench/contrib/performance/browser/perfviewEditor.ts @@ -215,7 +215,7 @@ class PerfModelContentProvider implements ITextModelContentProvider { table.push(['restore secondary viewlet', metrics.timers.ellapsedAuxiliaryViewletRestore, '[renderer]', metrics.auxiliaryViewletId]); table.push(['restore panel', metrics.timers.ellapsedPanelRestore, '[renderer]', metrics.panelId]); table.push(['restore & resolve visible editors', metrics.timers.ellapsedEditorRestore, '[renderer]', `${metrics.editorIds.length}: ${metrics.editorIds.join(', ')}`]); - table.push(['create workbench contributions', metrics.timers.ellapsedWorkbenchContributions, '[renderer]', `${(contribTimings.get(LifecyclePhase.Starting)?.length ?? 0) + (contribTimings.get(LifecyclePhase.Starting)?.length ?? 0)} blocking startup`]); + table.push(['create workbench contributions', metrics.timers.ellapsedWorkbenchContributions, '[renderer]', `${(contribTimings.get(LifecyclePhase.Starting)?.length ?? 0) + (contribTimings.get(LifecyclePhase.Ready)?.length ?? 0)} blocking startup`]); table.push(['overall workbench load', metrics.timers.ellapsedWorkbench, '[renderer]', undefined]); table.push(['workbench ready', metrics.ellapsed, '[main->renderer]', undefined]); table.push(['renderer ready', metrics.timers.ellapsedRenderer, '[renderer]', undefined]); diff --git a/src/vs/workbench/contrib/terminal/browser/terminalEditor.ts b/src/vs/workbench/contrib/terminal/browser/terminalEditor.ts index b158684ae0e..8b40d469375 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalEditor.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalEditor.ts @@ -13,7 +13,6 @@ import { IContextKeyService } from '../../../../platform/contextkey/common/conte import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; @@ -61,7 +60,6 @@ export class TerminalEditor extends EditorPane { @ITerminalProfileResolverService private readonly _terminalProfileResolverService: ITerminalProfileResolverService, @ITerminalService private readonly _terminalService: ITerminalService, @ITerminalConfigurationService private readonly _terminalConfigurationService: ITerminalConfigurationService, - @IConfigurationService private readonly _configurationService: IConfigurationService, @IContextKeyService contextKeyService: IContextKeyService, @IMenuService menuService: IMenuService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @@ -158,7 +156,7 @@ export class TerminalEditor extends EditorPane { private _updateTabActionBar(profiles: ITerminalProfile[]): void { this._disposableStore.clear(); - const actions = getTerminalActionBarArgs(TerminalLocation.Editor, profiles, this._getDefaultProfileName(), this._terminalProfileService.contributedProfiles, this._terminalService, this._dropdownMenu, this._disposableStore, this._configurationService); + const actions = getTerminalActionBarArgs(TerminalLocation.Editor, profiles, this._getDefaultProfileName(), this._terminalProfileService.contributedProfiles, this._terminalService, this._dropdownMenu, this._disposableStore); this._newDropdown.value?.update(actions.dropdownAction, actions.dropdownMenuActions); } @@ -182,7 +180,7 @@ export class TerminalEditor extends EditorPane { if (action instanceof MenuItemAction) { const location = { viewColumn: ACTIVE_GROUP }; this._disposableStore.clear(); - const actions = getTerminalActionBarArgs(location, this._terminalProfileService.availableProfiles, this._getDefaultProfileName(), this._terminalProfileService.contributedProfiles, this._terminalService, this._dropdownMenu, this._disposableStore, this._configurationService); + const actions = getTerminalActionBarArgs(location, this._terminalProfileService.availableProfiles, this._getDefaultProfileName(), this._terminalProfileService.contributedProfiles, this._terminalService, this._dropdownMenu, this._disposableStore); this._newDropdown.value = this._instantiationService.createInstance(DropdownWithPrimaryActionViewItem, action, actions.dropdownAction, actions.dropdownMenuActions, actions.className, { hoverDelegate: options.hoverDelegate }); this._newDropdown.value?.update(actions.dropdownAction, actions.dropdownMenuActions); return this._newDropdown.value; diff --git a/src/vs/workbench/contrib/terminal/browser/terminalMenus.ts b/src/vs/workbench/contrib/terminal/browser/terminalMenus.ts index 281d624fcec..4e645b1cd3f 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalMenus.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalMenus.ts @@ -8,7 +8,6 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { Schemas } from '../../../../base/common/network.js'; import { localize, localize2 } from '../../../../nls.js'; import { IMenu, MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { IExtensionTerminalProfile, ITerminalProfile, TerminalLocation, TerminalSettingId } from '../../../../platform/terminal/common/terminal.js'; import { ResourceContextKey } from '../../../common/contextkeys.js'; @@ -782,20 +781,15 @@ export function setupTerminalMenus(): void { } } -export function getTerminalActionBarArgs(location: ITerminalLocationOptions, profiles: ITerminalProfile[], defaultProfileName: string, contributedProfiles: readonly IExtensionTerminalProfile[], terminalService: ITerminalService, dropdownMenu: IMenu, disposableStore: DisposableStore, configurationService: IConfigurationService): { +export function getTerminalActionBarArgs(location: ITerminalLocationOptions, profiles: ITerminalProfile[], defaultProfileName: string, contributedProfiles: readonly IExtensionTerminalProfile[], terminalService: ITerminalService, dropdownMenu: IMenu, disposableStore: DisposableStore): { dropdownAction: IAction; dropdownMenuActions: IAction[]; className: string; dropdownIcon?: string; } { - const shouldElevateAiProfiles = configurationService.getValue(TerminalSettingId.ExperimentalAiProfileGrouping); profiles = profiles.filter(e => !e.isAutoDetected); - const [aiProfiles, otherProfiles] = shouldElevateAiProfiles - ? splitProfiles(profiles) - : [[], profiles]; - const [aiContributedProfiles, otherContributedProfiles] = shouldElevateAiProfiles - ? splitContributedProfiles(contributedProfiles) - : [[], contributedProfiles]; + const [aiProfiles, otherProfiles] = splitProfiles(profiles); + const [aiContributedProfiles, otherContributedProfiles] = splitContributedProfiles(contributedProfiles); const dropdownActions: IAction[] = []; const submenuActions: IAction[] = []; const splitLocation = (location === TerminalLocation.Editor || (typeof location === 'object' && hasKey(location, { viewColumn: true }) && location.viewColumn === ACTIVE_GROUP)) ? { viewColumn: SIDE_GROUP } : { splitActiveTerminal: true }; diff --git a/src/vs/workbench/contrib/terminal/browser/terminalView.ts b/src/vs/workbench/contrib/terminal/browser/terminalView.ts index 01632bee153..9a745727415 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalView.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalView.ts @@ -289,7 +289,7 @@ export class TerminalViewPane extends ViewPane { case TerminalCommandId.New: { if (action instanceof MenuItemAction) { this._disposableStore.clear(); - const actions = getTerminalActionBarArgs(TerminalLocation.Panel, this._terminalProfileService.availableProfiles, this._getDefaultProfileName(), this._terminalProfileService.contributedProfiles, this._terminalService, this._dropdownMenu, this._disposableStore, this._configurationService); + const actions = getTerminalActionBarArgs(TerminalLocation.Panel, this._terminalProfileService.availableProfiles, this._getDefaultProfileName(), this._terminalProfileService.contributedProfiles, this._terminalService, this._dropdownMenu, this._disposableStore); this._newDropdown.value = this._instantiationService.createInstance(DropdownWithPrimaryActionViewItem, action, actions.dropdownAction, actions.dropdownMenuActions, actions.className, { hoverDelegate: options.hoverDelegate, getKeyBinding: (action: IAction) => this._keybindingService.lookupKeybinding(action.id, this._contextKeyService) @@ -318,15 +318,8 @@ export class TerminalViewPane extends ViewPane { private _updateTabActionBar(profiles: ITerminalProfile[]): void { this._disposableStore.clear(); - const actions = getTerminalActionBarArgs(TerminalLocation.Panel, profiles, this._getDefaultProfileName(), this._terminalProfileService.contributedProfiles, this._terminalService, this._dropdownMenu, this._disposableStore, this._configurationService); + const actions = getTerminalActionBarArgs(TerminalLocation.Panel, profiles, this._getDefaultProfileName(), this._terminalProfileService.contributedProfiles, this._terminalService, this._dropdownMenu, this._disposableStore); this._newDropdown.value?.update(actions.dropdownAction, actions.dropdownMenuActions); - - this._disposableStore.add(this._configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(TerminalSettingId.ExperimentalAiProfileGrouping)) { - const updatedActions = getTerminalActionBarArgs(TerminalLocation.Panel, profiles, this._getDefaultProfileName(), this._terminalProfileService.contributedProfiles, this._terminalService, this._dropdownMenu, this._disposableStore, this._configurationService); - this._newDropdown.value?.update(updatedActions.dropdownAction, updatedActions.dropdownMenuActions); - } - })); } override focus() { diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index be681d6ef06..6f915a27195 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts @@ -604,15 +604,6 @@ const terminalConfiguration: IStringDictionary = { mode: 'auto' } }, - [TerminalSettingId.ExperimentalAiProfileGrouping]: { - markdownDescription: localize('terminal.integrated.experimental.aiProfileGrouping', "Whether to elevate AI-contributed terminal profiles (for example Copilot CLI and Claude Agent) in the new terminal dropdown."), - type: 'boolean', - default: false, - tags: ['experimental'], - experiment: { - mode: 'auto' - } - }, [TerminalSettingId.ShellIntegrationEnabled]: { restricted: true, markdownDescription: localize('terminal.integrated.shellIntegration.enabled', "Determines whether or not shell integration is auto-injected to support features like enhanced command tracking and current working directory detection. \n\nShell integration works by injecting the shell with a startup script. The script gives VS Code insight into what is happening within the terminal.\n\nSupported shells:\n\n- Linux/macOS: bash, fish, pwsh, zsh\n - Windows: pwsh, git bash\n\nThis setting applies only when terminals are created, so you will need to restart your terminals for it to take effect.\n\n Note that the script injection may not work if you have custom arguments defined in the terminal profile, have enabled {1}, have a [complex bash `PROMPT_COMMAND`](https://code.visualstudio.com/docs/editor/integrated-terminal#_complex-bash-promptcommand), or other unsupported setup. To disable decorations, see {0}", '`#terminal.integrated.shellIntegration.decorationsEnabled#`', '`#editor.accessibilitySupport#`'), diff --git a/src/vs/workbench/test/browser/componentFixtures/agentSessionsViewer.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/agentSessionsViewer.fixture.ts index 2b098ab3158..2fcc86f53b8 100644 --- a/src/vs/workbench/test/browser/componentFixtures/agentSessionsViewer.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/agentSessionsViewer.fixture.ts @@ -135,7 +135,7 @@ function renderSectionItem(ctx: ComponentFixtureContext, section: IAgentSessionS }, }); - const renderer = instantiationService.createInstance(AgentSessionSectionRenderer); + const renderer = instantiationService.createInstance(AgentSessionSectionRenderer, {}); container.style.width = '350px'; container.style.height = 'auto'; diff --git a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts index 465246d562f..737a184ee5b 100644 --- a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts @@ -24,6 +24,7 @@ import { IWorkspace, IWorkspaceContextService, WorkbenchState } from '../../../. import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; +import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; import { IPathService } from '../../../services/path/common/pathService.js'; import { IWorkingCopyService } from '../../../services/workingCopy/common/workingCopyService.js'; import { IWebviewService } from '../../../contrib/webview/browser/webview.js'; @@ -45,6 +46,12 @@ import { IWorkbenchLocalMcpServer, LocalMcpServerScope } from '../../../services import { McpListWidget } from '../../../contrib/chat/browser/aiCustomization/mcpListWidget.js'; import { PluginListWidget } from '../../../contrib/chat/browser/aiCustomization/pluginListWidget.js'; import { IIterativePager } from '../../../../base/common/paging.js'; +// eslint-disable-next-line local/code-import-patterns +import { IAgentFeedbackService } from '../../../../sessions/contrib/agentFeedback/browser/agentFeedbackService.js'; +// eslint-disable-next-line local/code-import-patterns +import { CodeReviewStateKind, ICodeReviewService, ICodeReviewState, IPRReviewState, PRReviewStateKind } from '../../../../sessions/contrib/codeReview/browser/codeReviewService.js'; +import { IChatEditingService } from '../../../contrib/chat/common/editing/chatEditingService.js'; +import { IAgentSessionsService } from '../../../contrib/chat/browser/agentSessions/agentSessionsService.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from './fixtureUtils.js'; // Ensure theme colors & widget CSS are loaded @@ -66,6 +73,8 @@ interface IFixtureFile { readonly name?: string; readonly description?: string; readonly applyTo?: string; + readonly extensionId?: string; + readonly extensionDisplayName?: string; } function createMockEditorGroup(): IEditorGroup { @@ -74,6 +83,17 @@ function createMockEditorGroup(): IEditorGroup { }(); } +function toExtensionInfo(file: IFixtureFile): { identifier: ExtensionIdentifier; displayName?: string } | undefined { + if (!file.extensionId) { + return undefined; + } + + return { + identifier: new ExtensionIdentifier(file.extensionId), + displayName: file.extensionDisplayName, + }; +} + function createMockPromptsService(files: IFixtureFile[], agentInstructions: IResolvedAgentFile[]): IPromptsService { const applyToMap = new ResourceMap(); const descriptionMap = new ResourceMap(); @@ -84,16 +104,24 @@ function createMockPromptsService(files: IFixtureFile[], agentInstructions: IRes override readonly onDidChangeSkills = Event.None; override readonly onDidChangeInstructions = Event.None; override getDisabledPromptFiles(): ResourceSet { return new ResourceSet(); } - override async listPromptFiles(type: PromptsType) { + override async listPromptFiles(type: PromptsType, _token: CancellationToken) { return files.filter(f => f.type === type).map(f => ({ - uri: f.uri, storage: f.storage as PromptsStorage.local, type: f.type, name: f.name, description: f.description, + uri: f.uri, + storage: f.storage as PromptsStorage.local, + type: f.type, + name: f.name, + description: f.description, + extension: toExtensionInfo(f) as never, })); } override async listAgentInstructions() { return agentInstructions; } override async getCustomAgents() { return files.filter(f => f.type === PromptsType.agent).map(a => ({ uri: a.uri, name: a.name ?? 'agent', description: a.description, storage: a.storage, - source: { storage: a.storage }, + source: { + storage: a.storage, + extensionId: a.extensionId ? new ExtensionIdentifier(a.extensionId) : undefined, + }, })) as never[]; } override async parseNew(uri: URI, _token: CancellationToken): Promise { @@ -118,7 +146,7 @@ function createMockPromptsService(files: IFixtureFile[], agentInstructions: IRes override async getPromptSlashCommands(): Promise { const promptFiles = files.filter(f => f.type === PromptsType.prompt); const commands = await Promise.all(promptFiles.map(async f => { - const promptPath = { uri: f.uri, storage: f.storage, type: f.type }; + const promptPath = { uri: f.uri, storage: f.storage, type: f.type, extension: toExtensionInfo(f) as never }; const parsedPromptFile = await this.parseNew(f.uri, CancellationToken.None); return { name: f.name ?? 'prompt', @@ -164,11 +192,55 @@ function makeLocalMcpServer(id: string, label: string, scope: LocalMcpServerScop }(); } +function createMockAgentFeedbackService(): IAgentFeedbackService { + return new class extends mock() { + override readonly onDidChangeFeedback = Event.None; + override readonly onDidChangeNavigation = Event.None; + override getFeedback() { return []; } + override getMostRecentSessionForResource() { return undefined; } + override async revealFeedback(): Promise { } + override getNextFeedback() { return undefined; } + override getNavigationBearing() { return { activeIdx: -1, totalCount: 0 }; } + override getNextNavigableItem() { return undefined; } + override setNavigationAnchor(): void { } + override clearFeedback(): void { } + override removeFeedback(): void { } + override async addFeedbackAndSubmit(): Promise { } + }(); +} + +function createMockCodeReviewService(): ICodeReviewService { + return new class extends mock() { + private readonly reviewState = observableValue('fixture.reviewState', { kind: CodeReviewStateKind.Idle }); + private readonly prReviewState = observableValue('fixture.prReviewState', { kind: PRReviewStateKind.None }); + + override getReviewState() { + return this.reviewState; + } + + override getPRReviewState() { + return this.prReviewState; + } + + override hasReview(): boolean { + return false; + } + + override requestReview(): void { } + override removeComment(): void { } + override dismissReview(): void { } + override async resolvePRReviewThread(): Promise { } + }(); +} + // ============================================================================ // Realistic test data — a project that has Copilot + Claude customizations // ============================================================================ const allFiles: IFixtureFile[] = [ + // Instructions - extension (built-in + third-party) + { uri: URI.file('/extensions/github.copilot-chat/instructions/coding.instructions.md'), storage: PromptsStorage.extension, type: PromptsType.instructions, name: 'Copilot Coding', description: 'Built-in coding guidance', extensionId: 'GitHub.copilot-chat', extensionDisplayName: 'GitHub Copilot Chat' }, + { uri: URI.file('/extensions/acme.tools/instructions/team.instructions.md'), storage: PromptsStorage.extension, type: PromptsType.instructions, name: 'Team Conventions', description: 'Third-party extension instructions', extensionId: 'acme.tools', extensionDisplayName: 'Acme Tools' }, // Instructions — workspace { uri: URI.file('/workspace/.github/instructions/coding-standards.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Coding Standards', description: 'Repository-wide coding standards' }, { uri: URI.file('/workspace/.github/instructions/testing.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Testing', description: 'Testing best practices', applyTo: '**/*.test.ts' }, @@ -198,6 +270,9 @@ const allFiles: IFixtureFile[] = [ { uri: URI.file('/home/dev/.copilot/agents/planner.agent.md'), storage: PromptsStorage.user, type: PromptsType.agent, name: 'Planner', description: 'Project planning agent' }, { uri: URI.file('/home/dev/.copilot/agents/debugger.agent.md'), storage: PromptsStorage.user, type: PromptsType.agent, name: 'Debugger', description: 'Interactive debugging assistant' }, { uri: URI.file('/home/dev/.copilot/agents/nls-helper.agent.md'), storage: PromptsStorage.user, type: PromptsType.agent, name: 'NLS Helper', description: 'Natural language searching code for clarity' }, + // Agents - extension (built-in + third-party) + { uri: URI.file('/extensions/github.copilot-chat/agents/workspace-guide.agent.md'), storage: PromptsStorage.extension, type: PromptsType.agent, name: 'Workspace Guide', description: 'Built-in workspace exploration agent', extensionId: 'GitHub.copilot-chat', extensionDisplayName: 'GitHub Copilot Chat' }, + { uri: URI.file('/extensions/acme.tools/agents/api-helper.agent.md'), storage: PromptsStorage.extension, type: PromptsType.agent, name: 'API Helper', description: 'Third-party API agent', extensionId: 'acme.tools', extensionDisplayName: 'Acme Tools' }, // Skills — workspace { uri: URI.file('/workspace/.github/skills/deploy/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Deploy', description: 'Deployment automation' }, { uri: URI.file('/workspace/.github/skills/refactor/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Refactor', description: 'Code refactoring patterns' }, @@ -210,6 +285,9 @@ const allFiles: IFixtureFile[] = [ // Skills — user { uri: URI.file('/home/dev/.copilot/skills/git-workflow/SKILL.md'), storage: PromptsStorage.user, type: PromptsType.skill, name: 'Git Workflow', description: 'Branch and PR workflows' }, { uri: URI.file('/home/dev/.copilot/skills/code-review/SKILL.md'), storage: PromptsStorage.user, type: PromptsType.skill, name: 'Code Review', description: 'Structured code review checklist' }, + // Skills - extension (built-in + third-party) + { uri: URI.file('/extensions/github.copilot-chat/skills/workspace/SKILL.md'), storage: PromptsStorage.extension, type: PromptsType.skill, name: 'Workspace Search', description: 'Built-in workspace search skill', extensionId: 'GitHub.copilot-chat', extensionDisplayName: 'GitHub Copilot Chat' }, + { uri: URI.file('/extensions/acme.tools/skills/audit/SKILL.md'), storage: PromptsStorage.extension, type: PromptsType.skill, name: 'Audit', description: 'Third-party audit skill', extensionId: 'acme.tools', extensionDisplayName: 'Acme Tools' }, // Prompts — workspace { uri: URI.file('/workspace/.github/prompts/explain.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Explain', description: 'Explain selected code' }, { uri: URI.file('/workspace/.github/prompts/review.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Review', description: 'Review changes' }, @@ -222,6 +300,9 @@ const allFiles: IFixtureFile[] = [ // Prompts — user { uri: URI.file('/home/dev/.copilot/prompts/translate.prompt.md'), storage: PromptsStorage.user, type: PromptsType.prompt, name: 'Translate', description: 'Translate strings for i18n' }, { uri: URI.file('/home/dev/.copilot/prompts/commit-msg.prompt.md'), storage: PromptsStorage.user, type: PromptsType.prompt, name: 'Commit Message', description: 'Generate conventional commit' }, + // Prompts - extension (built-in + third-party) + { uri: URI.file('/extensions/github.copilot-chat/prompts/trace.prompt.md'), storage: PromptsStorage.extension, type: PromptsType.prompt, name: 'Trace', description: 'Built-in tracing prompt', extensionId: 'GitHub.copilot-chat', extensionDisplayName: 'GitHub Copilot Chat' }, + { uri: URI.file('/extensions/acme.tools/prompts/lint.prompt.md'), storage: PromptsStorage.extension, type: PromptsType.prompt, name: 'Lint', description: 'Third-party lint prompt', extensionId: 'acme.tools', extensionDisplayName: 'Acme Tools' }, // Hooks — workspace { uri: URI.file('/workspace/.github/hooks/pre-commit.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'Pre-Commit Lint', description: 'Run linting before commit' }, { uri: URI.file('/workspace/.github/hooks/post-save.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'Post-Save Format', description: 'Auto-format on save' }, @@ -267,6 +348,66 @@ interface IRenderEditorOptions { readonly managementSections?: readonly AICustomizationManagementSection[]; readonly availableHarnesses?: readonly IHarnessDescriptor[]; readonly selectedSection?: AICustomizationManagementSection; + readonly scrollToBottom?: boolean; + readonly width?: number; + readonly height?: number; +} + +async function waitForAnimationFrames(count: number): Promise { + for (let i = 0; i < count; i++) { + await new Promise(resolve => mainWindow.requestAnimationFrame(() => resolve())); + } +} + +function getVisibleEditorSignature(container: HTMLElement): string { + const sectionCounts = [...container.querySelectorAll('.section-list-item')].map(item => item.textContent?.replace(/\s+/g, ' ').trim() ?? '').join('|'); + const visibleContent = [...container.querySelectorAll('.prompts-content-container, .mcp-content-container, .plugin-content-container')] + .find(node => node instanceof HTMLElement && node.style.display !== 'none'); + const visibleRows = visibleContent + ? [...visibleContent.querySelectorAll('.monaco-list-row')].map(row => row.textContent?.replace(/\s+/g, ' ').trim() ?? '').join('|') + : ''; + + return `${sectionCounts}@@${visibleRows}`; +} + +async function waitForEditorToSettle(container: HTMLElement): Promise { + let previousSignature = ''; + let stableIterations = 0; + + await new Promise(resolve => setTimeout(resolve, 150)); + + for (let i = 0; i < 20; i++) { + await waitForAnimationFrames(2); + await new Promise(resolve => setTimeout(resolve, 25)); + + const signature = getVisibleEditorSignature(container); + if (signature && signature === previousSignature) { + stableIterations++; + if (stableIterations >= 2) { + return; + } + } else { + stableIterations = 0; + previousSignature = signature; + } + } +} + +async function waitForVisibleScrollbarsToFade(container: HTMLElement): Promise { + const deadline = Date.now() + 4000; + + while (Date.now() < deadline) { + const hasVisibleScrollbar = [...container.querySelectorAll('.scrollbar.vertical')].some(scrollbar => { + const style = mainWindow.getComputedStyle(scrollbar); + return scrollbar.classList.contains('visible') && style.opacity !== '0'; + }); + + if (!hasVisibleScrollbar) { + return; + } + + await new Promise(resolve => setTimeout(resolve, 100)); + } } // ============================================================================ @@ -274,8 +415,8 @@ interface IRenderEditorOptions { // ============================================================================ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditorOptions): Promise { - const width = 900; - const height = 600; + const width = options.width ?? 900; + const height = options.height ?? 600; ctx.container.style.width = `${width}px`; ctx.container.style.height = `${height}px`; @@ -290,7 +431,7 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor AICustomizationManagementSection.Plugins, ]; const availableHarnesses = options.availableHarnesses ?? [ - createVSCodeHarnessDescriptor([PromptsStorage.extension]), + createVSCodeHarnessDescriptor([PromptsStorage.extension, BUILTIN_STORAGE]), createCliHarnessDescriptor(getCliUserRoots(userHome), []), createClaudeHarnessDescriptor(getClaudeUserRoots(userHome), []), ]; @@ -301,8 +442,21 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor colorTheme: ctx.theme, additionalServices: (reg) => { const harnessService = createMockHarnessService(options.harness, availableHarnesses); + const agentFeedbackService = createMockAgentFeedbackService(); + const codeReviewService = createMockCodeReviewService(); registerWorkbenchServices(reg); reg.define(IListService, ListService); + reg.defineInstance(IAgentFeedbackService, agentFeedbackService); + reg.defineInstance(ICodeReviewService, codeReviewService); + reg.defineInstance(IChatEditingService, new class extends mock() { + override readonly editingSessionsObs = constObservable([]); + }()); + reg.defineInstance(IAgentSessionsService, new class extends mock() { + override readonly model = new class extends mock() { + override readonly sessions = []; + }(); + override getSession() { return undefined; } + }()); reg.defineInstance(IPromptsService, createMockPromptsService(allFiles, agentInstructions)); reg.defineInstance(IAICustomizationWorkspaceService, new class extends mock() { override readonly isSessionsWindow = isSessionsWindow; @@ -372,7 +526,11 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor override readonly onDidChangeMarketplaces = Event.None; }()); reg.defineInstance(IPluginInstallService, new class extends mock() { }()); - reg.defineInstance(IProductService, new class extends mock() { }()); + reg.defineInstance(IProductService, new class extends mock() { + override readonly defaultChatAgent = new class extends mock>() { + override readonly chatExtensionId = 'GitHub.copilot-chat'; + }(); + }()); }, }); @@ -382,16 +540,19 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor editor.create(ctx.container); editor.layout(new Dimension(width, height)); - // setInput may fail on unmocked service calls — catch to still show the editor shell - try { - await editor.setInput(AICustomizationManagementEditorInput.getOrCreate(), undefined, {}, CancellationToken.None); - } catch { - // Expected in fixture — some services are partially mocked - } + await editor.setInput(AICustomizationManagementEditorInput.getOrCreate(), undefined, {}, CancellationToken.None); if (options.selectedSection) { editor.selectSectionById(options.selectedSection); - editor.layout(new Dimension(width, height)); + } + + await waitForEditorToSettle(ctx.container); + + if (options.scrollToBottom) { + editor.revealLastItem(); + await waitForAnimationFrames(2); + await new Promise(resolve => setTimeout(resolve, 2400)); + await waitForVisibleScrollbarsToFade(ctx.container); } } @@ -475,7 +636,7 @@ async function renderMcpBrowseMode(ctx: ComponentFixtureContext): Promise }()); reg.defineInstance(ICustomizationHarnessService, new class extends mock() { override readonly activeHarness = observableValue('activeHarness', CustomizationHarness.VSCode); - override getActiveDescriptor() { return createVSCodeHarnessDescriptor([PromptsStorage.extension]); } + override getActiveDescriptor() { return createVSCodeHarnessDescriptor([PromptsStorage.extension, BUILTIN_STORAGE]); } }()); }, }); @@ -603,7 +764,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { // Full editor with Local (VS Code) harness — all sections visible, harness dropdown, // Generate buttons, AGENTS.md shortcut, all storage groups LocalHarness: defineComponentFixture({ - labels: { kind: 'screenshot' }, + labels: { kind: 'screenshot', blocksCi: true }, render: ctx => renderEditor(ctx, { harness: CustomizationHarness.VSCode }), }), @@ -645,7 +806,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { // MCP Servers tab with many servers to verify scrollable list layout McpServersTab: defineComponentFixture({ - labels: { kind: 'screenshot' }, + labels: { kind: 'screenshot', blocksCi: true }, render: ctx => renderEditor(ctx, { harness: CustomizationHarness.VSCode, selectedSection: AICustomizationManagementSection.McpServers, @@ -718,4 +879,53 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { labels: { kind: 'screenshot' }, render: renderPluginBrowseMode, }), + + // Scrolled-to-bottom variants — verify last items are fully visible above footer + PromptsTabScrolled: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.Prompts, + scrollToBottom: true, + }), + }), + + McpServersTabScrolled: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.McpServers, + scrollToBottom: true, + }), + }), + + PluginsTabScrolled: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.Plugins, + scrollToBottom: true, + }), + }), + + // Narrow viewport — catches badge clipping and layout overflow at small sizes + McpServersTabNarrow: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.McpServers, + width: 550, + height: 400, + }), + }), + + AgentsTabNarrow: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.Agents, + width: 550, + height: 400, + }), + }), });