mirror of
https://github.com/microsoft/vscode.git
synced 2026-05-08 17:19:48 +01:00
Merge branch 'main' into connor4312/fix-plugin-root
This commit is contained in:
@@ -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
|
||||
|
||||
Vendored
+24
-14
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -304,13 +304,6 @@ export interface IBrowserViewService {
|
||||
*/
|
||||
captureScreenshot(id: string, options?: IBrowserViewCaptureScreenshotOptions): Promise<VSBuffer>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
|
||||
/**
|
||||
* Focus the browser view
|
||||
* @param id The browser view identifier
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<IBrowserViewNavigationEvent>());
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<string, DebugSession>());
|
||||
|
||||
/** 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) {
|
||||
|
||||
@@ -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<void> {
|
||||
return this._getBrowserView(id).dispatchKeyEvent(keyEvent);
|
||||
}
|
||||
|
||||
async focus(id: string): Promise<void> {
|
||||
return this._getBrowserView(id).focus();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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(
|
||||
`<span class="working-set-lines-added">+${added}</span> <span class="working-set-lines-removed">-${removed}</span>`,
|
||||
{ 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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<readonly ISessionTaskWithTarget[]>(this, []);
|
||||
private readonly _fileWatcher = this._register(new MutableDisposable());
|
||||
private readonly _knownSessionWorktrees = new Map<string, string | undefined>();
|
||||
private readonly _pinnedTaskLabels: Map<string, string>;
|
||||
private readonly _pinnedTaskObservables = new Map<string, ReturnType<typeof observableValue<string | undefined>>>();
|
||||
|
||||
@@ -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<readonly ISessionTaskWithTarget[]> {
|
||||
@@ -384,65 +378,10 @@ export class SessionsConfigurationService extends Disposable implements ISession
|
||||
}
|
||||
}
|
||||
|
||||
private async _readAllTasks(session: IActiveSessionItem): Promise<readonly ITaskEntry[]> {
|
||||
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<void> {
|
||||
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()) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -245,7 +245,7 @@ export class BrowserEditorInput extends EditorInput {
|
||||
return {
|
||||
resource: this.resource,
|
||||
options: {
|
||||
override: BrowserEditorInput.ID,
|
||||
override: BrowserEditorInput.EDITOR_ID,
|
||||
viewState
|
||||
}
|
||||
};
|
||||
|
||||
@@ -198,7 +198,6 @@ export interface IBrowserViewModel extends IDisposable {
|
||||
reload(hard?: boolean): Promise<void>;
|
||||
toggleDevTools(): Promise<void>;
|
||||
captureScreenshot(options?: IBrowserViewCaptureScreenshotOptions): Promise<VSBuffer>;
|
||||
dispatchKeyEvent(keyEvent: IBrowserViewKeyDownEvent): Promise<void>;
|
||||
focus(): Promise<void>;
|
||||
findInPage(text: string, options?: IBrowserViewFindInPageOptions): Promise<void>;
|
||||
stopFindInPage(keepSelection?: boolean): Promise<void>;
|
||||
@@ -471,10 +470,6 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel {
|
||||
return result;
|
||||
}
|
||||
|
||||
async dispatchKeyEvent(keyEvent: IBrowserViewKeyDownEvent): Promise<void> {
|
||||
return this.browserViewService.dispatchKeyEvent(this.id, keyEvent);
|
||||
}
|
||||
|
||||
async focus(): Promise<void> {
|
||||
return this.browserViewService.focus(this.id);
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<IListStyles>;
|
||||
@@ -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<string, AgentSessionListItem>();
|
||||
|
||||
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<AgentSessionOpenedEvent, AgentSessionOpenedClassification>('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<AgentSessionListItem>): Promise<void> {
|
||||
if (!element || isAgentSessionShowMore(element)) {
|
||||
if (!element || isAgentSessionShowMore(element) || isAgentSessionShowLess(element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'}`);
|
||||
|
||||
@@ -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<IAgentSession, FuzzyScore>, 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<IAgentSession, FuzzyScore>, template: IAgentSessionItemTemplate): boolean {
|
||||
@@ -587,6 +631,10 @@ interface IAgentSessionSectionTemplate {
|
||||
readonly disposables: IDisposable;
|
||||
}
|
||||
|
||||
export interface IAgentSessionSectionRendererOptions {
|
||||
readonly hideSectionCount?: boolean;
|
||||
}
|
||||
|
||||
export class AgentSessionSectionRenderer implements ICompressibleTreeRenderer<IAgentSessionSection, FuzzyScore, IAgentSessionSectionTemplate> {
|
||||
|
||||
static readonly TEMPLATE_ID = 'agent-session-section';
|
||||
@@ -594,6 +642,7 @@ export class AgentSessionSectionRenderer implements ICompressibleTreeRenderer<IA
|
||||
readonly templateId = AgentSessionSectionRenderer.TEMPLATE_ID;
|
||||
|
||||
constructor(
|
||||
private readonly sectionOptions: IAgentSessionSectionRendererOptions,
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||
@IContextKeyService private readonly contextKeyService: IContextKeyService,
|
||||
) { }
|
||||
@@ -634,7 +683,11 @@ export class AgentSessionSectionRenderer implements ICompressibleTreeRenderer<IA
|
||||
template.label.textContent = element.element.label;
|
||||
|
||||
// Count
|
||||
template.count.textContent = String(element.element.sessions.length);
|
||||
if (this.sectionOptions.hideSectionCount) {
|
||||
template.count.textContent = '';
|
||||
} else {
|
||||
template.count.textContent = String(element.element.sessions.length);
|
||||
}
|
||||
|
||||
// Toolbar
|
||||
ChatContextKeys.agentSessionSection.bindTo(template.contextKeyService).set(element.element.section);
|
||||
@@ -656,7 +709,7 @@ export class AgentSessionSectionRenderer implements ICompressibleTreeRenderer<IA
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Show More Renderer
|
||||
//#region Show More / Show Less Renderer
|
||||
|
||||
interface IAgentSessionShowMoreTemplate {
|
||||
readonly container: HTMLElement;
|
||||
@@ -664,13 +717,20 @@ interface IAgentSessionShowMoreTemplate {
|
||||
readonly disposables: DisposableStore;
|
||||
}
|
||||
|
||||
export interface IAgentSessionShowMoreRendererOptions {
|
||||
readonly compactLabel?: boolean;
|
||||
}
|
||||
|
||||
export class AgentSessionShowMoreRenderer implements ICompressibleTreeRenderer<IAgentSessionShowMore, FuzzyScore, IAgentSessionShowMoreTemplate> {
|
||||
|
||||
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<I
|
||||
}
|
||||
|
||||
renderElement(element: ITreeNode<IAgentSessionShowMore, FuzzyScore>, _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<I
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentSessionShowLessRenderer implements ICompressibleTreeRenderer<IAgentSessionShowLess, FuzzyScore, IAgentSessionShowMoreTemplate> {
|
||||
|
||||
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<IAgentSessionShowLess, FuzzyScore>, _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<AgentSessionListItem> {
|
||||
@@ -710,15 +813,17 @@ export class AgentSessionsListDelegate implements IListVirtualDelegate<AgentSess
|
||||
static readonly ITEM_HEIGHT = 54;
|
||||
static readonly SECTION_HEIGHT = 26;
|
||||
|
||||
constructor(private readonly _approvalModel?: AgentSessionApprovalModel) { }
|
||||
constructor(private readonly _approvalModel?: AgentSessionApprovalModel,
|
||||
private readonly _compactShowMore?: boolean,
|
||||
) { }
|
||||
|
||||
getHeight(element: AgentSessionListItem): number {
|
||||
if (isAgentSessionSection(element)) {
|
||||
return AgentSessionsListDelegate.SECTION_HEIGHT;
|
||||
}
|
||||
|
||||
if (isAgentSessionShowMore(element)) {
|
||||
return AgentSessionShowMoreRenderer.HEIGHT;
|
||||
if (isAgentSessionShowMore(element) || isAgentSessionShowLess(element)) {
|
||||
return this._compactShowMore ? AgentSessionShowMoreRenderer.COLLAPSED_HEIGHT : AgentSessionShowMoreRenderer.HEIGHT;
|
||||
}
|
||||
|
||||
let height = AgentSessionsListDelegate.ITEM_HEIGHT;
|
||||
@@ -730,6 +835,9 @@ export class AgentSessionsListDelegate implements IListVirtualDelegate<AgentSess
|
||||
}
|
||||
|
||||
hasDynamicHeight(element: AgentSessionListItem): boolean {
|
||||
if (isAgentSessionShowMore(element) || isAgentSessionShowLess(element)) {
|
||||
return true;
|
||||
}
|
||||
return !!this._approvalModel && isAgentSession(element);
|
||||
}
|
||||
|
||||
@@ -742,6 +850,10 @@ export class AgentSessionsListDelegate implements IListVirtualDelegate<AgentSess
|
||||
return AgentSessionShowMoreRenderer.TEMPLATE_ID;
|
||||
}
|
||||
|
||||
if (isAgentSessionShowLess(element)) {
|
||||
return AgentSessionShowLessRenderer.TEMPLATE_ID;
|
||||
}
|
||||
|
||||
return AgentSessionRenderer.TEMPLATE_ID;
|
||||
}
|
||||
}
|
||||
@@ -769,6 +881,10 @@ export class AgentSessionsAccessibilityProvider implements IListAccessibilityPro
|
||||
return localize('agentSessionShowMoreAriaLabel', "Show {0} more sessions", element.remainingCount);
|
||||
}
|
||||
|
||||
if (isAgentSessionShowLess(element)) {
|
||||
return localize('agentSessionShowLessAriaLabel', "Show less sessions");
|
||||
}
|
||||
|
||||
return localize('agentSessionItemAriaLabel', "{0} session {1} ({2}), created {3}", element.providerLabel, element.label, toStatusLabel(element.status), new Date(element.timing.created).toLocaleString());
|
||||
}
|
||||
}
|
||||
@@ -872,6 +988,11 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou
|
||||
this._onDidExpandRepositoryGroup.fire();
|
||||
}
|
||||
|
||||
collapseRepositoryGroup(sectionLabel: string): void {
|
||||
this.expandedRepositoryGroups.delete(sectionLabel);
|
||||
this._onDidExpandRepositoryGroup.fire();
|
||||
}
|
||||
|
||||
hasChildren(element: IAgentSessionsModel | AgentSessionListItem): boolean {
|
||||
|
||||
// Sessions model
|
||||
@@ -925,10 +1046,16 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou
|
||||
// Sessions section
|
||||
else if (isAgentSessionSection(element)) {
|
||||
const isCappingEnabled = this.repositoryGroupLimit && this.filter?.getExcludes().repositoryGroupCapped;
|
||||
if (isCappingEnabled && element.section === AgentSessionSection.Repository && !this.expandedRepositoryGroups.has(element.label) && element.sessions.length > 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<IAgentSe
|
||||
return `show-more-${element.sectionLabel}`;
|
||||
}
|
||||
|
||||
if (isAgentSessionShowLess(element)) {
|
||||
return `show-less-${element.sectionLabel}`;
|
||||
}
|
||||
|
||||
if (isAgentSession(element)) {
|
||||
return element.resource.toString();
|
||||
}
|
||||
@@ -1422,6 +1553,10 @@ export class AgentSessionsKeyboardNavigationLabelProvider implements ICompressib
|
||||
return element.sectionLabel;
|
||||
}
|
||||
|
||||
if (isAgentSessionShowLess(element)) {
|
||||
return element.sectionLabel;
|
||||
}
|
||||
|
||||
return element.label;
|
||||
}
|
||||
|
||||
@@ -1445,8 +1580,8 @@ export class AgentSessionsDragAndDrop extends Disposable implements ITreeDragAnd
|
||||
}
|
||||
|
||||
getDragURI(element: AgentSessionListItem): string | null {
|
||||
if (isAgentSessionSection(element) || isAgentSessionShowMore(element)) {
|
||||
return null; // section headers and show-more items are not draggable
|
||||
if (isAgentSessionSection(element) || isAgentSessionShowMore(element) || isAgentSessionShowLess(element)) {
|
||||
return null; // section headers, show-more and show-less items are not draggable
|
||||
}
|
||||
|
||||
return element.resource.toString();
|
||||
|
||||
@@ -121,6 +121,9 @@ export class LocalAgentsSessionsController extends Disposable implements IChatSe
|
||||
}
|
||||
|
||||
description = this.chatSessionsService.getInProgressSessionDescription(model);
|
||||
} else if (chat.isActive) {
|
||||
// Sessions that are active but don't have a chat model are ultimately untitled with no requests
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -44,11 +44,12 @@
|
||||
.agent-session-diff-container {
|
||||
.agent-session-diff-added,
|
||||
.agent-session-diff-removed {
|
||||
color: unset;
|
||||
color: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.monaco-list-row.selected .agent-session-item,
|
||||
.monaco-list-row.selected .agent-session-title {
|
||||
color: unset;
|
||||
}
|
||||
@@ -151,6 +152,14 @@
|
||||
color: var(--vscode-agentSessionReadIndicator-foreground);
|
||||
}
|
||||
|
||||
&.codicon.codicon-git-pull-request {
|
||||
color: var(--vscode-charts-green);
|
||||
}
|
||||
|
||||
&.codicon.codicon-git-merge {
|
||||
color: var(--vscode-charts-purple);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
&.codicon.codicon-circle-filled.needs-input {
|
||||
animation: none;
|
||||
@@ -181,6 +190,18 @@
|
||||
max-height: 15px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
|
||||
.agent-session-details-icon {
|
||||
display: none;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.visible {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.rendered-markdown {
|
||||
p {
|
||||
display: flex;
|
||||
|
||||
+79
-72
@@ -108,6 +108,10 @@ export interface IAICustomizationListItem {
|
||||
readonly badgeTooltip?: string;
|
||||
/** When set, overrides the default prompt-type icon. */
|
||||
readonly typeIcon?: ThemeIcon;
|
||||
/** True when item comes from the default chat extension (grouped under Built-in). */
|
||||
readonly isBuiltin?: boolean;
|
||||
/** Display name of the contributing extension (for non-built-in extension items). */
|
||||
readonly extensionLabel?: string;
|
||||
nameMatches?: IMatch[];
|
||||
descriptionMatches?: IMatch[];
|
||||
}
|
||||
@@ -266,16 +270,12 @@ function storageToIcon(storage: PromptsStorage): ThemeIcon {
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a name for display: strips a trailing .md extension, converts dashes/underscores
|
||||
* to spaces and applies title case.
|
||||
* Note: callers that pass IMatch highlight ranges must compute those ranges against the
|
||||
* formatted string (not the raw input), since .md stripping changes string length.
|
||||
* Formats a name for display by stripping a trailing .md extension.
|
||||
* Names from frontmatter headers are shown as-is to stay consistent
|
||||
* with how they appear in agent dropdowns and error messages.
|
||||
*/
|
||||
export function formatDisplayName(name: string): string {
|
||||
return name
|
||||
.replace(/\.md$/i, '')
|
||||
.replace(/[-_]/g, ' ')
|
||||
.replace(/\b\w/g, c => c.toUpperCase());
|
||||
return name.replace(/\.md$/i, '');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -334,10 +334,18 @@ class AICustomizationItemRenderer implements IListRenderer<IFileItemEntry, IAICu
|
||||
templateData.typeIcon.className = 'item-type-icon';
|
||||
templateData.typeIcon.classList.add(...ThemeIcon.asClassNameArray(element.typeIcon ?? promptTypeToIcon(element.promptType)));
|
||||
|
||||
// Hover tooltip: name + full path + badge context + plugin source
|
||||
// Hover tooltip: name + source + badge context + plugin source
|
||||
templateData.elementDisposables.add(this.hoverService.setupDelayedHover(templateData.container, () => {
|
||||
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<string, ExtensionIdentifier>): 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<string, { id: ExtensionIdentifier; displayName?: string }>): 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<string, ExtensionIdentifier>();
|
||||
const extensionInfoByUri = new Map<string, { id: ExtensionIdentifier; displayName?: string }>();
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+18
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1215,7 +1215,9 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ
|
||||
}
|
||||
|
||||
public async forkChatSession(sessionResource: URI, request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken): Promise<IChatSessionItem> {
|
||||
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`);
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
async installPluginFromSource(source: string, options?: IInstallPluginFromSourceOptions): Promise<void> {
|
||||
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<IInstallPluginFromSourceResult> {
|
||||
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<IInstallPluginFromSourceResult> {
|
||||
// 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<unknown[]>(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<boolean> {
|
||||
|
||||
@@ -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=<base64>` and
|
||||
* Handles `vscode://chat-plugin/install?source=<base64>[&plugin=<base64>]` and
|
||||
* `vscode://chat-plugin/add-marketplace?ref=<base64>` 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<boolean> {
|
||||
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<boolean> {
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void>;
|
||||
installPluginFromSource(source: string, options?: IInstallPluginFromSourceOptions): Promise<void>;
|
||||
|
||||
/**
|
||||
* 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<IInstallPluginFromSourceResult>;
|
||||
|
||||
/**
|
||||
* Pulls the latest changes for an already-cloned marketplace repository.
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-3
@@ -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', () => {
|
||||
|
||||
@@ -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<MockState>): { 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);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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`));
|
||||
});
|
||||
|
||||
+1
-1
@@ -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');
|
||||
|
||||
|
||||
@@ -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<URI[]> {
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, string>();
|
||||
|
||||
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 = `<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; media-src blob: data:; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}';">
|
||||
<style nonce="${nonce}">html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:transparent}
|
||||
video{width:100%;height:100%;object-fit:contain;outline:none}</style>
|
||||
</head><body>
|
||||
<video id="v" controls></video>
|
||||
<script nonce="${nonce}">
|
||||
window.addEventListener("message",function(e){var m=e.data;if(m.type==="loadVideo"){var b=new Blob([m.data],{type:m.mimeType});document.getElementById("v").src=URL.createObjectURL(b);}});
|
||||
</script>
|
||||
</body></html>`;
|
||||
|
||||
// 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<ArrayBuffer>).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<Uint8Array> {
|
||||
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 */ });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,3 +27,7 @@ export interface IImageCarouselCollection {
|
||||
readonly title: string;
|
||||
readonly sections: ReadonlyArray<ICarouselSection>;
|
||||
}
|
||||
|
||||
export function isVideoMimeType(mimeType: string): boolean {
|
||||
return mimeType.startsWith('video/');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+65
@@ -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<string, IFileStat>();
|
||||
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<string, IFileStat>();
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<boolean>(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 };
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -604,15 +604,6 @@ const terminalConfiguration: IStringDictionary<IConfigurationPropertySchema> = {
|
||||
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#`'),
|
||||
|
||||
@@ -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';
|
||||
|
||||
+228
-18
@@ -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<string | undefined>();
|
||||
const descriptionMap = new ResourceMap<string | undefined>();
|
||||
@@ -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<ParsedPromptFile> {
|
||||
@@ -118,7 +146,7 @@ function createMockPromptsService(files: IFixtureFile[], agentInstructions: IRes
|
||||
override async getPromptSlashCommands(): Promise<readonly IChatPromptSlashCommand[]> {
|
||||
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<IAgentFeedbackService>() {
|
||||
override readonly onDidChangeFeedback = Event.None;
|
||||
override readonly onDidChangeNavigation = Event.None;
|
||||
override getFeedback() { return []; }
|
||||
override getMostRecentSessionForResource() { return undefined; }
|
||||
override async revealFeedback(): Promise<void> { }
|
||||
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<void> { }
|
||||
}();
|
||||
}
|
||||
|
||||
function createMockCodeReviewService(): ICodeReviewService {
|
||||
return new class extends mock<ICodeReviewService>() {
|
||||
private readonly reviewState = observableValue<ICodeReviewState>('fixture.reviewState', { kind: CodeReviewStateKind.Idle });
|
||||
private readonly prReviewState = observableValue<IPRReviewState>('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<void> { }
|
||||
}();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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<void> {
|
||||
for (let i = 0; i < count; i++) {
|
||||
await new Promise<void>(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<void> {
|
||||
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<void> {
|
||||
const deadline = Date.now() + 4000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const hasVisibleScrollbar = [...container.querySelectorAll<HTMLElement>('.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<void> {
|
||||
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<IChatEditingService>() {
|
||||
override readonly editingSessionsObs = constObservable([]);
|
||||
}());
|
||||
reg.defineInstance(IAgentSessionsService, new class extends mock<IAgentSessionsService>() {
|
||||
override readonly model = new class extends mock<IAgentSessionsService['model']>() {
|
||||
override readonly sessions = [];
|
||||
}();
|
||||
override getSession() { return undefined; }
|
||||
}());
|
||||
reg.defineInstance(IPromptsService, createMockPromptsService(allFiles, agentInstructions));
|
||||
reg.defineInstance(IAICustomizationWorkspaceService, new class extends mock<IAICustomizationWorkspaceService>() {
|
||||
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<IPluginInstallService>() { }());
|
||||
reg.defineInstance(IProductService, new class extends mock<IProductService>() { }());
|
||||
reg.defineInstance(IProductService, new class extends mock<IProductService>() {
|
||||
override readonly defaultChatAgent = new class extends mock<NonNullable<IProductService['defaultChatAgent']>>() {
|
||||
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<void>
|
||||
}());
|
||||
reg.defineInstance(ICustomizationHarnessService, new class extends mock<ICustomizationHarnessService>() {
|
||||
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,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user