Merge branch 'main' into copilot/fix-suggest-widget-placement

This commit is contained in:
Johannes Rieken
2025-12-05 10:05:14 +01:00
committed by GitHub
82 changed files with 2029 additions and 512 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
---
# NOTE: This prompt is intended for internal use only for now.
agent: Engineering
argument-hint: "Provide an issue number to find duplicates"
argument-hint: Provide a link or issue number to find duplicates for
description: Find duplicates for a VS Code GitHub issue
model: Claude Sonnet 4.5 (copilot)
tools:
- execute/getTerminalOutput
+2 -1
View File
@@ -2,7 +2,8 @@
# ⚠️: Internal use only. To onboard, follow instructions at https://github.com/microsoft/vscode-engineering/blob/main/docs/gh-mcp-onboarding.md
agent: Engineering
model: Claude Sonnet 4.5 (copilot)
argument-hint: "Describe your issue..."
argument-hint: Describe your issue. Include relevant keywords or phrases.
description: Search for an existing VS Code GitHub issue
tools:
- github/*
- agent/runSubagent
@@ -18,7 +18,7 @@ suite('vscode API - tree', () => {
assertNoRpc();
});
test('TreeView - element already registered', async function () {
test.skip('TreeView - element already registered', async function () {
this.timeout(60_000);
type TreeElement = { readonly kind: 'leaf' };
@@ -103,4 +103,143 @@ suite('vscode API - tree', () => {
assert.fail(error.message);
}
});
test('TreeView - element already registered after refresh', async function () {
this.timeout(60_000);
type ParentElement = { readonly kind: 'parent' };
type ChildElement = { readonly kind: 'leaf'; readonly version: number };
type TreeElement = ParentElement | ChildElement;
class ParentRefreshTreeDataProvider implements vscode.TreeDataProvider<TreeElement> {
private readonly changeEmitter = new vscode.EventEmitter<TreeElement | undefined>();
private readonly rootRequestEmitter = new vscode.EventEmitter<number>();
private readonly childRequestEmitter = new vscode.EventEmitter<number>();
private readonly rootRequests: DeferredPromise<TreeElement[]>[] = [];
private readonly childRequests: DeferredPromise<TreeElement[]>[] = [];
private readonly parentElement: ParentElement = { kind: 'parent' };
private childVersion = 0;
private currentChild: ChildElement = { kind: 'leaf', version: 0 };
readonly onDidChangeTreeData = this.changeEmitter.event;
getChildren(element?: TreeElement): Thenable<TreeElement[]> {
if (!element) {
const deferred = new DeferredPromise<TreeElement[]>();
this.rootRequests.push(deferred);
this.rootRequestEmitter.fire(this.rootRequests.length);
return deferred.p;
}
if (element.kind === 'parent') {
const deferred = new DeferredPromise<TreeElement[]>();
this.childRequests.push(deferred);
this.childRequestEmitter.fire(this.childRequests.length);
return deferred.p;
}
return Promise.resolve([]);
}
getTreeItem(element: TreeElement): vscode.TreeItem {
if (element.kind === 'parent') {
const item = new vscode.TreeItem('parent', vscode.TreeItemCollapsibleState.Collapsed);
item.id = 'parent';
return item;
}
const item = new vscode.TreeItem('duplicate', vscode.TreeItemCollapsibleState.None);
item.id = 'dup';
return item;
}
getParent(element: TreeElement): TreeElement | undefined {
if (element.kind === 'leaf') {
return this.parentElement;
}
return undefined;
}
getCurrentChild(): ChildElement {
return this.currentChild;
}
replaceChild(): ChildElement {
this.childVersion++;
this.currentChild = { kind: 'leaf', version: this.childVersion };
return this.currentChild;
}
async waitForRootRequestCount(count: number): Promise<void> {
while (this.rootRequests.length < count) {
await asPromise(this.rootRequestEmitter.event);
}
}
async waitForChildRequestCount(count: number): Promise<void> {
while (this.childRequests.length < count) {
await asPromise(this.childRequestEmitter.event);
}
}
async resolveNextRootRequest(elements?: TreeElement[]): Promise<void> {
const next = this.rootRequests.shift();
if (!next) {
return;
}
await next.complete(elements ?? [this.parentElement]);
}
async resolveChildRequestAt(index: number, elements?: TreeElement[]): Promise<void> {
const request = this.childRequests[index];
if (!request) {
return;
}
this.childRequests.splice(index, 1);
await request.complete(elements ?? [this.currentChild]);
}
dispose(): void {
this.changeEmitter.dispose();
this.rootRequestEmitter.dispose();
this.childRequestEmitter.dispose();
while (this.rootRequests.length) {
this.rootRequests.shift()!.complete([]);
}
while (this.childRequests.length) {
this.childRequests.shift()!.complete([]);
}
}
}
const provider = new ParentRefreshTreeDataProvider();
disposables.push(provider);
const treeView = vscode.window.createTreeView('test.treeRefresh', { treeDataProvider: provider });
disposables.push(treeView);
const initialChild = provider.getCurrentChild();
const firstReveal = (treeView.reveal(initialChild, { expand: true })
.then(() => ({ error: undefined as Error | undefined })) as Promise<{ error: Error | undefined }>)
.catch(error => ({ error }));
await provider.waitForRootRequestCount(1);
await provider.resolveNextRootRequest();
await provider.waitForChildRequestCount(1);
const staleChild = provider.getCurrentChild();
const refreshedChild = provider.replaceChild();
const secondReveal = (treeView.reveal(refreshedChild, { expand: true })
.then(() => ({ error: undefined as Error | undefined })) as Promise<{ error: Error | undefined }>)
.catch(error => ({ error }));
await provider.waitForChildRequestCount(2);
await provider.resolveChildRequestAt(1, [refreshedChild]);
await delay(0);
await provider.resolveChildRequestAt(0, [staleChild]);
const [firstResult, secondResult] = await Promise.all([firstReveal, secondReveal]);
const error = firstResult.error ?? secondResult.error;
if (error && /Element with id .+ is already registered/.test(error.message)) {
assert.fail(error.message);
}
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "code-oss-dev",
"version": "1.107.0",
"version": "1.108.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "code-oss-dev",
"version": "1.107.0",
"version": "1.108.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "code-oss-dev",
"version": "1.107.0",
"distro": "f7daaf68414ef6e47bec698f8babc297f0d90f0d",
"version": "1.108.0",
"distro": "f7ac66cb4d31a00eed97a9e72bc381bed8191387",
"author": {
"name": "Microsoft Corporation"
},
+2 -2
View File
@@ -12,8 +12,8 @@ export namespace Iterable {
}
const _empty: Iterable<never> = Object.freeze([]);
export function empty<T = never>(): Iterable<T> {
return _empty as Iterable<T>;
export function empty<T = never>(): readonly never[] {
return _empty as readonly never[];
}
export function* single<T>(element: T): Iterable<T> {
@@ -10,6 +10,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js';
import { derived, observableValue, recomputeInitiallyAndOnChange } from '../../../../base/common/observable.js';
import { URI } from '../../../../base/common/uri.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
import { Range } from '../../../common/core/range.js';
import { IDiffEditor } from '../../../common/editorCommon.js';
import { ICodeEditor } from '../../editorBrowser.js';
@@ -82,6 +83,18 @@ export class MultiDiffEditorWidget extends Disposable {
return this._widgetImpl.get().tryGetCodeEditor(resource);
}
public getRootElement(): HTMLElement {
return this._widgetImpl.get().getRootElement();
}
public getContextKeyService(): IContextKeyService {
return this._widgetImpl.get().getContextKeyService();
}
public getScopedInstantiationService(): IInstantiationService {
return this._widgetImpl.get().getScopedInstantiationService();
}
public findDocumentDiffItem(resource: URI): IDocumentDiffItem | undefined {
return this._widgetImpl.get().findDocumentDiffItem(resource);
}
@@ -259,6 +259,17 @@ export class MultiDiffEditorWidgetImpl extends Disposable {
this._scrollableElement.setScrollPosition({ scrollLeft: scrollState.left, scrollTop: scrollState.top });
}
public getRootElement(): HTMLElement {
return this._elements.root;
}
public getContextKeyService(): IContextKeyService {
return this._contextKeyService;
}
public getScopedInstantiationService(): IInstantiationService {
return this._instantiationService;
}
public reveal(resource: IMultiDiffResourceId, options?: RevealOptions): void {
const viewItems = this._viewItems.get();
const index = viewItems.findIndex(
@@ -34,6 +34,54 @@
}
}
> .multi-diff-root-floating-menu {
position: absolute;
right: 32px;
bottom: 32px;
top: auto;
left: auto;
height: auto;
width: auto;
padding: 4px 6px;
color: var(--vscode-button-foreground);
background-color: var(--vscode-button-background);
border-radius: 4px;
border: 1px solid var(--vscode-contrastBorder);
display: flex;
align-items: center;
z-index: 10;
box-shadow: 0 3px 12px var(--vscode-widget-shadow);
overflow: hidden;
}
.multi-diff-root-floating-menu .action-item > .action-label {
padding: 7px 8px;
font-size: 15px;
border-radius: 2px;
}
.multi-diff-root-floating-menu .action-item > .action-label.codicon {
color: var(--vscode-button-foreground);
}
.multi-diff-root-floating-menu .action-item > .action-label.codicon:not(.separator) {
padding-top: 6px;
padding-bottom: 6px;
}
.multi-diff-root-floating-menu .action-item:first-child > .action-label {
padding-left: 7px;
}
.multi-diff-root-floating-menu .action-item:last-child > .action-label {
padding-right: 7px;
}
.multi-diff-root-floating-menu .action-item .action-label.separator {
background-color: var(--vscode-button-separator);
}
.active {
--vscode-multiDiffEditor-border: var(--vscode-focusBorder);
}
@@ -21,7 +21,7 @@
border-radius: 2px;
}
.action-item > .action-label.codicon {
.action-item > .action-label.codicon, .action-item .codicon {
color: var(--vscode-button-foreground);
}
@@ -7,7 +7,7 @@ import { Command } from '../../../../common/languages.js';
export type InlineSuggestAlternativeAction = {
label: string;
icon?: ThemeIcon;
icon: ThemeIcon;
command: Command;
count: Promise<number>;
};
@@ -919,12 +919,14 @@ export class InlineCompletionsModel extends Disposable {
completion.addRef();
try {
let followUpTrigger = false;
editor.pushUndoStop();
if (isNextEditUri) {
// Do nothing
} else if (completion.action?.kind === 'edit') {
const action = completion.action;
if (action.alternativeAction && alternativeAction) {
if (alternativeAction && action.alternativeAction) {
followUpTrigger = true;
const altCommand = action.alternativeAction.command;
await this._commandService
.executeCommand(altCommand.id, ...(altCommand.arguments || []))
@@ -979,6 +981,11 @@ export class InlineCompletionsModel extends Disposable {
.then(undefined, onUnexpectedExternalError);
}
// TODO: how can we make alternative actions to retrigger?
if (followUpTrigger) {
this.trigger(undefined);
}
completion.reportEndOfLife({ kind: InlineCompletionEndOfLifeReasonKind.Accepted });
} finally {
completion.removeRef();
@@ -34,11 +34,12 @@ export namespace InlineSuggestionItem {
export function create(
data: InlineSuggestData,
textModel: ITextModel,
shouldDiffEdit: boolean = true, // TODO@benibenj it should only be created once and hence not meeded to be passed here
): InlineSuggestionItem {
if (!data.isInlineEdit && !data.action?.uri && data.action?.kind === 'edit') {
return InlineCompletionItem.create(data, textModel, data.action);
} else {
return InlineEditItem.create(data, textModel);
return InlineEditItem.create(data, textModel, shouldDiffEdit);
}
}
}
@@ -371,11 +372,12 @@ export class InlineEditItem extends InlineSuggestionItemBase {
public static create(
data: InlineSuggestData,
textModel: ITextModel,
shouldDiffEdit: boolean = true,
): InlineEditItem {
let action: InlineSuggestionAction | undefined;
let edits: SingleUpdatedNextEdit[] = [];
if (data.action?.kind === 'edit') {
const offsetEdit = getStringEdit(textModel, data.action.range, data.action.insertText); // TODO compute async
const offsetEdit = shouldDiffEdit ? getDiffedStringEdit(textModel, data.action.range, data.action.insertText) : getStringEdit(textModel, data.action.range, data.action.insertText); // TODO compute async
const text = new TextModelText(textModel);
const textEdit = TextEdit.fromStringEdit(offsetEdit, text);
const singleTextEdit = offsetEdit.isEmpty() ? new TextReplacement(new Range(1, 1, 1, 1), '') : textEdit.toReplacement(text); // FIXME: .toReplacement() can throw because offsetEdit is empty because we get an empty diff in getStringEdit after diffing
@@ -549,7 +551,7 @@ export class InlineEditItem extends InlineSuggestionItemBase {
}
}
function getStringEdit(textModel: ITextModel, editRange: Range, replaceText: string): StringEdit {
function getDiffedStringEdit(textModel: ITextModel, editRange: Range, replaceText: string): StringEdit {
const eol = textModel.getEOL();
const editOriginalText = textModel.getValueInRange(editRange);
const editReplaceText = replaceText.replace(/\r\n|\r|\n/g, eol);
@@ -591,6 +593,13 @@ function getStringEdit(textModel: ITextModel, editRange: Range, replaceText: str
return offsetEdit;
}
function getStringEdit(textModel: ITextModel, editRange: Range, replaceText: string): StringEdit {
return new StringEdit([new StringReplacement(
getPositionOffsetTransformerFromTextModel(textModel).getOffsetRange(editRange),
replaceText
)]);
}
class SingleUpdatedNextEdit {
public static create(
edit: StringReplacement,
@@ -25,6 +25,7 @@ import { renameSymbolCommandId } from '../controller/commandIds.js';
import { InlineSuggestionItem } from './inlineSuggestionItem.js';
import { IInlineSuggestDataActionEdit } from './provideInlineCompletions.js';
import { InlineSuggestAlternativeAction } from './InlineSuggestAlternativeAction.js';
import { Codicon } from '../../../../../base/common/codicons.js';
enum RenameKind {
no = 'no',
@@ -170,12 +171,29 @@ export class RenameInferenceEngine {
tokenDiff += diff;
continue;
}
const tokenInfo = this.getTokenAtPosition(textModel, startPos);
const originalStartColumn = change.originalStart + 1;
const isInsertion = change.originalLength === 0 && change.modifiedLength > 0;
let tokenInfo: { type: StandardTokenType; range: Range };
// Word info is left aligned whereas token info is right aligned for insertions.
// We prefer a suffix insertion for renames so we take the word range for the token info.
if (isInsertion && originalStartColumn === wordRange.endColumn && wordRange.endColumn > wordRange.startColumn) {
tokenInfo = this.getTokenAtPosition(textModel, new Position(startPos.lineNumber, wordRange.startColumn));
} else {
tokenInfo = this.getTokenAtPosition(textModel, startPos);
}
if (wordRange.startColumn !== tokenInfo.range.startColumn || wordRange.endColumn !== tokenInfo.range.endColumn) {
others.push(new TextReplacement(range, insertedTextSegment));
tokenDiff += diff;
continue;
}
if (tokenInfo.type === StandardTokenType.Other) {
let identifier = textModel.getValueInRange(tokenInfo.range);
if (identifier.length === 0) {
others.push(new TextReplacement(range, insertedTextSegment));
tokenDiff += diff;
continue;
}
if (oldName === undefined) {
oldName = identifier;
} else if (oldName !== identifier) {
@@ -188,6 +206,11 @@ export class RenameInferenceEngine {
const tokenStartPos = textModel.getOffsetAt(tokenInfo.range.getStartPosition()) - nesOffset + tokenDiff;
const tokenEndPos = textModel.getOffsetAt(tokenInfo.range.getEndPosition()) - nesOffset + tokenDiff;
identifier = modifiedText.substring(tokenStartPos, tokenEndPos + diff);
if (identifier.length === 0) {
others.push(new TextReplacement(range, insertedTextSegment));
tokenDiff += diff;
continue;
}
if (newName === undefined) {
newName = identifier;
} else if (newName !== identifier) {
@@ -200,7 +223,11 @@ export class RenameInferenceEngine {
position = tokenInfo.range.getStartPosition();
}
renames.push(new TextReplacement(range, insertedTextSegment));
if (oldName !== undefined && newName !== undefined && oldName.length > 0 && newName.length > 0 && oldName !== newName) {
renames.push(new TextReplacement(tokenInfo.range, newName));
} else {
renames.push(new TextReplacement(range, insertedTextSegment));
}
tokenDiff += diff;
} else {
others.push(new TextReplacement(range, insertedTextSegment));
@@ -317,7 +344,6 @@ export class RenameSymbolProcessor extends Disposable {
}
public async proposeRenameRefactoring(textModel: ITextModel, suggestItem: InlineSuggestionItem): Promise<InlineSuggestionItem> {
//console.log('Propose rename refactoring for inline suggestion');
if (!suggestItem.supportsRename || suggestItem.action?.kind !== 'edit') {
return suggestItem;
}
@@ -340,7 +366,7 @@ export class RenameSymbolProcessor extends Disposable {
// Check asynchronously if a rename is possible
let timedOut = false;
const check = await raceTimeout<RenameKind>(this.checkRenamePrecondition(suggestItem, textModel, position, oldName, newName), 1000, () => { timedOut = true; });
const check = await raceTimeout<RenameKind>(this.checkRenamePrecondition(suggestItem, textModel, position, oldName, newName), 100, () => { timedOut = true; });
const renamePossible = check === RenameKind.yes || check === RenameKind.maybe;
suggestItem.setRenameProcessingInfo({
@@ -379,7 +405,7 @@ export class RenameSymbolProcessor extends Disposable {
};
const alternativeAction: InlineSuggestAlternativeAction = {
label: localize('rename', "Rename"),
//icon: Codicon.replaceAll,
icon: Codicon.replaceAll,
command,
count: runnable.getCount(),
};
@@ -392,7 +418,7 @@ export class RenameSymbolProcessor extends Disposable {
uri: textModel.uri
};
return InlineSuggestionItem.create(suggestItem.withAction(renameAction), textModel);
return InlineSuggestionItem.create(suggestItem.withAction(renameAction), textModel, false);
}
private async checkRenamePrecondition(suggestItem: InlineSuggestionItem, textModel: ITextModel, position: Position, oldName: string, newName: string): Promise<RenameKind> {
@@ -26,7 +26,7 @@ import { defaultKeybindingLabelStyles } from '../../../../../../../platform/them
import { asCssVariable, descriptionForeground, editorActionListForeground, editorHoverBorder } from '../../../../../../../platform/theme/common/colorRegistry.js';
import { ObservableCodeEditor } from '../../../../../../browser/observableCodeEditor.js';
import { EditorOption } from '../../../../../../common/config/editorOptions.js';
import { hideInlineCompletionId, inlineSuggestCommitId, toggleShowCollapsedId } from '../../../controller/commandIds.js';
import { hideInlineCompletionId, inlineSuggestCommitAlternativeActionId, inlineSuggestCommitId, toggleShowCollapsedId } from '../../../controller/commandIds.js';
import { FirstFnArg, } from '../utils/utils.js';
import { InlineSuggestionGutterMenuData } from './gutterIndicatorView.js';
@@ -71,7 +71,7 @@ export class GutterIndicatorMenuContent {
id: 'gotoAndAccept',
title: `${localize('goto', "Go To")} / ${localize('accept', "Accept")}`,
icon: Codicon.check,
commandId: inlineSuggestCommitId
commandId: inlineSuggestCommitId,
}));
const reject = option(createOptionArgs({
@@ -81,6 +81,13 @@ export class GutterIndicatorMenuContent {
commandId: hideInlineCompletionId
}));
const alternativeCommand = this._data.alternativeAction ? option(createOptionArgs({
id: 'alternativeCommand',
title: this._data.alternativeAction.command.title,
icon: this._data.alternativeAction.icon,
commandId: inlineSuggestCommitAlternativeActionId,
})) : undefined;
const extensionCommands = this._data.extensionCommands.map((c, idx) => option(createOptionArgs({
id: c.command.id + '_' + idx,
title: c.command.title,
@@ -148,6 +155,7 @@ export class GutterIndicatorMenuContent {
return hoverContent([
title,
gotoAndAccept,
alternativeCommand,
reject,
toggleCollapsedMode,
modelOptions.length ? separator() : undefined,
@@ -46,10 +46,12 @@ export class InlineEditsGutterIndicatorData {
export class InlineSuggestionGutterMenuData {
public static fromInlineSuggestion(suggestion: InlineSuggestionItem): InlineSuggestionGutterMenuData {
const alternativeAction = suggestion.action?.kind === 'edit' ? suggestion.action.alternativeAction : undefined;
return new InlineSuggestionGutterMenuData(
suggestion.gutterMenuLinkAction,
suggestion.source.provider.displayName ?? localize('inlineSuggestion', "Inline Suggestion"),
suggestion.source.inlineSuggestions.commands ?? [],
alternativeAction,
suggestion.source.provider.modelInfo,
suggestion.source.provider.setModelId?.bind(suggestion.source.provider),
);
@@ -59,6 +61,7 @@ export class InlineSuggestionGutterMenuData {
readonly action: Command | undefined,
readonly displayName: string,
readonly extensionCommands: InlineCompletionCommand[],
readonly alternativeAction: InlineSuggestAlternativeAction | undefined,
readonly modelInfo: IInlineCompletionModelInfo | undefined,
readonly setModelId: ((modelId: string) => Promise<void>) | undefined,
) { }
@@ -450,7 +453,7 @@ export class InlineEditsGutterIndicator extends Disposable {
},
).toDisposableLiveElement());
const focusTracker = disposableStore.add(trackFocus(content.element));
const focusTracker = disposableStore.add(trackFocus(content.element)); // TODO@benibenj should this be removed?
disposableStore.add(focusTracker.onDidBlur(() => this._focusIsInMenu.set(false, undefined)));
disposableStore.add(focusTracker.onDidFocus(() => this._focusIsInMenu.set(true, undefined)));
disposableStore.add(toDisposable(() => this._focusIsInMenu.set(false, undefined)));
@@ -487,7 +490,6 @@ export class InlineEditsGutterIndicator extends Disposable {
data.model.jump();
}
},
tabIndex: 0,
style: {
position: 'absolute',
overflow: 'visible',
@@ -122,10 +122,15 @@ export class InlineEditsWordReplacementView extends Disposable implements IInlin
const label = this._viewData.alternativeAction.label;
const count = altCount.read(reader);
const active = altModifierActive.read(reader);
const occurrencesLabel = count !== undefined ? count === 1 ?
localize('labelOccurence', "{0} 1 occurrence", label) :
localize('labelOccurences', "{0} {1} occurrences", label, count)
: label;
const keybindingTooltip = localize('shiftToSeeOccurences', "{0} show occurrences", '[shift]');
alternativeAction = {
label: count !== undefined ? (active ? localize('labelOccurances', "{0} {1} occurrences", label, count) : label) : label,
tooltip: count !== undefined ? localize('labelOccurances', "{0} {1} occurrences", label, count) : label,
icon: this._viewData.alternativeAction.icon,
label: count !== undefined ? (active ? occurrencesLabel : label) : label,
tooltip: occurrencesLabel ? `${occurrencesLabel}\n${keybindingTooltip}` : undefined,
icon: undefined, //this._viewData.alternativeAction.icon, Do not render icon fo the moment
count,
keybinding: this._keybindingService.lookupKeybinding(inlineSuggestCommitAlternativeActionId),
active: altModifierActive,
@@ -195,7 +200,7 @@ export class InlineEditsWordReplacementView extends Disposable implements IInlin
const primaryActionStyles = derived(this, r => alternativeActionActive.read(r) ? primaryActiveStyles : primaryActiveStyles);
const secondaryActionStyles = derived(this, r => alternativeActionActive.read(r) ? secondaryActiveStyles : passiveStyles);
// TODO@benibenj clicking the arrow does not accept suggestion anymore
return [
n.div({
style: {
@@ -209,7 +214,6 @@ export class InlineEditsWordReplacementView extends Disposable implements IInlin
style: {
position: 'absolute',
...rectToProps(reader => layout.read(reader).lowerBackground.withMargin(BORDER_WIDTH, 2 * BORDER_WIDTH, BORDER_WIDTH, 0)),
width: undefined,
background: asCssVariable(editorBackground),
},
onmousedown: e => {
@@ -290,7 +294,9 @@ export class InlineEditsWordReplacementView extends Disposable implements IInlin
this._secondaryElement.set(elem, undefined);
},
ref: (elem) => {
reader.store.add(this._hoverService.setupDelayedHoverAtMouse(elem, { content: altAction.tooltip, appearance: { compact: true } }));
if (altAction.tooltip) {
reader.store.add(this._hoverService.setupDelayedHoverAtMouse(elem, { content: altAction.tooltip, appearance: { compact: true } }));
}
}
}, [
keybinding,
@@ -263,7 +263,7 @@ export class LongDistancePreviewEditor extends Disposable {
// find the horizontal range we want to show.
const preferredRange = growUntilVariableBoundaries(editor.getModel()!, firstCharacterChange, 5);
const left = this._previewEditorObs.getLeftOfPosition(preferredRange.getStartPosition(), reader);
const right = trueContentWidth; //this._previewEditorObs.getLeftOfPosition(preferredRange.getEndPosition(), reader);
const right = Math.min(left, trueContentWidth); //this._previewEditorObs.getLeftOfPosition(preferredRange.getEndPosition(), reader);
const indentCol = editor.getModel()!.getLineFirstNonWhitespaceColumn(preferredRange.startLineNumber);
const indentationEnd = this._previewEditorObs.getLeftOfPosition(new Position(preferredRange.startLineNumber, indentCol), reader);
@@ -30,6 +30,9 @@ import { CharCode } from '../../../../../../../base/common/charCode.js';
import { BugIndicatingError } from '../../../../../../../base/common/errors.js';
import { Size2D } from '../../../../../../common/core/2d/size.js';
/**
* Warning: might return 0.
*/
export function maxContentWidthInRange(editor: ObservableCodeEditor, range: LineRange, reader: IReader | undefined): number {
editor.layoutInfo.read(reader);
editor.value.read(reader);
@@ -29,6 +29,10 @@ class TestRenameInferenceEngine extends RenameInferenceEngine {
}
}
function assertDefined<T>(value: T | undefined | null): asserts value is T {
assert.ok(value !== undefined && value !== null);
}
suite('renameSymbolProcessor', () => {
// This got copied from the TypeScript language configuration.
@@ -54,9 +58,16 @@ suite('renameSymbolProcessor', () => {
const renameInferenceEngine = new TestRenameInferenceEngine([{ type: StandardTokenType.Other, range: new Range(1, 7, 1, 10) }]);
const result = renameInferenceEngine.inferRename(model, new Range(1, 7, 1, 10), 'bar', wordPattern);
assert.strictEqual(result?.renames.edits.length, 1);
assert.strictEqual(result?.renames.oldName, 'foo');
assert.strictEqual(result?.renames.newName, 'bar');
assertDefined(result);
assert.strictEqual(result.renames.edits.length, 1);
assert.strictEqual(result.renames.oldName, 'foo');
assert.strictEqual(result.renames.newName, 'bar');
const edit = result.renames.edits[0];
assert.strictEqual(edit.range.startLineNumber, 1);
assert.strictEqual(edit.range.startColumn, 7);
assert.strictEqual(edit.range.endLineNumber, 1);
assert.strictEqual(edit.range.endColumn, 10);
assert.strictEqual(edit.text, 'bar');
});
test('Prefix rename - replacement', () => {
@@ -67,9 +78,16 @@ suite('renameSymbolProcessor', () => {
const renameInferenceEngine = new TestRenameInferenceEngine([{ type: StandardTokenType.Other, range: new Range(1, 7, 1, 13) }]);
const result = renameInferenceEngine.inferRename(model, new Range(1, 7, 1, 10), 'bazz', wordPattern);
assert.strictEqual(result?.renames.edits.length, 1);
assert.strictEqual(result?.renames.oldName, 'fooABC');
assert.strictEqual(result?.renames.newName, 'bazzABC');
assertDefined(result);
assert.strictEqual(result.renames.edits.length, 1);
assert.strictEqual(result.renames.oldName, 'fooABC');
assert.strictEqual(result.renames.newName, 'bazzABC');
const edit = result.renames.edits[0];
assert.strictEqual(edit.range.startLineNumber, 1);
assert.strictEqual(edit.range.startColumn, 7);
assert.strictEqual(edit.range.endLineNumber, 1);
assert.strictEqual(edit.range.endColumn, 13);
assert.strictEqual(edit.text, 'bazzABC');
});
test('Prefix rename - full line', () => {
@@ -80,9 +98,16 @@ suite('renameSymbolProcessor', () => {
const renameInferenceEngine = new TestRenameInferenceEngine([{ type: StandardTokenType.Other, range: new Range(1, 7, 1, 13) }]);
const result = renameInferenceEngine.inferRename(model, new Range(1, 1, 1, 18), 'const bazzABC = 1;', wordPattern);
assert.strictEqual(result?.renames.edits.length, 1);
assert.strictEqual(result?.renames.oldName, 'fooABC');
assert.strictEqual(result?.renames.newName, 'bazzABC');
assertDefined(result);
assert.strictEqual(result.renames.edits.length, 1);
assert.strictEqual(result.renames.oldName, 'fooABC');
assert.strictEqual(result.renames.newName, 'bazzABC');
const edit = result.renames.edits[0];
assert.strictEqual(edit.range.startLineNumber, 1);
assert.strictEqual(edit.range.startColumn, 7);
assert.strictEqual(edit.range.endLineNumber, 1);
assert.strictEqual(edit.range.endColumn, 13);
assert.strictEqual(edit.text, 'bazzABC');
});
test('Insertion - with whitespace', () => {
@@ -136,9 +161,16 @@ suite('renameSymbolProcessor', () => {
disposables.add(model);
const renameInferenceEngine = new TestRenameInferenceEngine([{ type: StandardTokenType.Other, range: new Range(1, 7, 1, 13) }]);
const result = renameInferenceEngine.inferRename(model, new Range(1, 10, 1, 13), 'bazz', wordPattern);
assert.strictEqual(result?.renames.edits.length, 1);
assert.strictEqual(result?.renames.oldName, 'ABCfoo');
assert.strictEqual(result?.renames.newName, 'ABCbazz');
assertDefined(result);
assert.strictEqual(result.renames.edits.length, 1);
assert.strictEqual(result.renames.oldName, 'ABCfoo');
assert.strictEqual(result.renames.newName, 'ABCbazz');
const edit = result.renames.edits[0];
assert.strictEqual(edit.range.startLineNumber, 1);
assert.strictEqual(edit.range.startColumn, 7);
assert.strictEqual(edit.range.endLineNumber, 1);
assert.strictEqual(edit.range.endColumn, 13);
assert.strictEqual(edit.text, 'ABCbazz');
});
test('Suffix rename - full line', () => {
@@ -148,9 +180,16 @@ suite('renameSymbolProcessor', () => {
disposables.add(model);
const renameInferenceEngine = new TestRenameInferenceEngine([{ type: StandardTokenType.Other, range: new Range(1, 7, 1, 13) }]);
const result = renameInferenceEngine.inferRename(model, new Range(1, 1, 1, 18), 'const ABCbazz = 1;', wordPattern);
assert.strictEqual(result?.renames.edits.length, 1);
assert.strictEqual(result?.renames.oldName, 'ABCfoo');
assert.strictEqual(result?.renames.newName, 'ABCbazz');
assertDefined(result);
assert.strictEqual(result.renames.oldName, 'ABCfoo');
assert.strictEqual(result.renames.newName, 'ABCbazz');
assert.strictEqual(result.renames.edits.length, 1);
const edit = result.renames.edits[0];
assert.strictEqual(edit.range.startLineNumber, 1);
assert.strictEqual(edit.range.startColumn, 7);
assert.strictEqual(edit.range.endLineNumber, 1);
assert.strictEqual(edit.range.endColumn, 13);
assert.strictEqual(edit.text, 'ABCbazz');
});
test('Prefix and suffix rename - full line', () => {
@@ -160,9 +199,16 @@ suite('renameSymbolProcessor', () => {
disposables.add(model);
const renameInferenceEngine = new TestRenameInferenceEngine([{ type: StandardTokenType.Other, range: new Range(1, 7, 1, 16) }]);
const result = renameInferenceEngine.inferRename(model, new Range(1, 1, 1, 21), 'const ABCfooXYZ = 1;', wordPattern);
assert.strictEqual(result?.renames.edits.length, 1);
assert.strictEqual(result?.renames.oldName, 'abcfooxyz');
assert.strictEqual(result?.renames.newName, 'ABCfooXYZ');
assertDefined(result);
assert.strictEqual(result.renames.edits.length, 1);
assert.strictEqual(result.renames.oldName, 'abcfooxyz');
assert.strictEqual(result.renames.newName, 'ABCfooXYZ');
const edit = result.renames.edits[0];
assert.strictEqual(edit.range.startLineNumber, 1);
assert.strictEqual(edit.range.startColumn, 7);
assert.strictEqual(edit.range.endLineNumber, 1);
assert.strictEqual(edit.range.endColumn, 16);
assert.strictEqual(edit.text, 'ABCfooXYZ');
});
test('Prefix and suffix rename - replacement', () => {
@@ -172,9 +218,16 @@ suite('renameSymbolProcessor', () => {
disposables.add(model);
const renameInferenceEngine = new TestRenameInferenceEngine([{ type: StandardTokenType.Other, range: new Range(1, 7, 1, 16) }]);
const result = renameInferenceEngine.inferRename(model, new Range(1, 7, 1, 16), 'ABCfooXYZ', wordPattern);
assert.strictEqual(result?.renames.edits.length, 1);
assert.strictEqual(result?.renames.oldName, 'abcfooxyz');
assert.strictEqual(result?.renames.newName, 'ABCfooXYZ');
assertDefined(result);
assert.strictEqual(result.renames.edits.length, 1);
assert.strictEqual(result.renames.oldName, 'abcfooxyz');
assert.strictEqual(result.renames.newName, 'ABCfooXYZ');
const edit = result.renames.edits[0];
assert.strictEqual(edit.range.startLineNumber, 1);
assert.strictEqual(edit.range.startColumn, 7);
assert.strictEqual(edit.range.endLineNumber, 1);
assert.strictEqual(edit.range.endColumn, 16);
assert.strictEqual(edit.text, 'ABCfooXYZ');
});
test('No rename - different identifiers - replacement', () => {
@@ -196,4 +249,23 @@ suite('renameSymbolProcessor', () => {
const result = renameInferenceEngine.inferRename(model, new Range(1, 1, 1, 18), 'const faz baz = 1;', wordPattern);
assert.ok(result === undefined);
});
test('Suffix insertion', () => {
const model = createTextModel([
'const w = 1;',
].join('\n'), 'typescript', {});
disposables.add(model);
const renameInferenceEngine = new TestRenameInferenceEngine([{ type: StandardTokenType.Other, range: new Range(1, 7, 1, 8) }, { type: StandardTokenType.Other, range: new Range(1, 8, 1, 9) }]);
const result = renameInferenceEngine.inferRename(model, new Range(1, 8, 1, 8), 'idth', wordPattern);
assertDefined(result);
assert.strictEqual(result.renames.edits.length, 1);
assert.strictEqual(result.renames.oldName, 'w');
assert.strictEqual(result.renames.newName, 'width');
const edit = result.renames.edits[0];
assert.strictEqual(edit.range.startLineNumber, 1);
assert.strictEqual(edit.range.startColumn, 7);
assert.strictEqual(edit.range.endLineNumber, 1);
assert.strictEqual(edit.range.endColumn, 8);
assert.strictEqual(edit.text, 'width');
});
});
+4 -2
View File
@@ -255,6 +255,7 @@ export class MenuId {
static readonly ChatInputSide = new MenuId('ChatInputSide');
static readonly ChatModePicker = new MenuId('ChatModePicker');
static readonly ChatEditingWidgetToolbar = new MenuId('ChatEditingWidgetToolbar');
static readonly ChatEditingSessionChangesToolbar = new MenuId('ChatEditingSessionChangesToolbar');
static readonly ChatEditingEditorContent = new MenuId('ChatEditingEditorContent');
static readonly ChatEditingEditorHunk = new MenuId('ChatEditingEditorHunk');
static readonly ChatEditingDeletedNotebookCell = new MenuId('ChatEditingDeletedNotebookCell');
@@ -278,6 +279,7 @@ export class MenuId {
static readonly ChatEditorInlineExecute = new MenuId('ChatEditorInputExecute');
static readonly ChatEditorInlineInputSide = new MenuId('ChatEditorInputSide');
static readonly AccessibleView = new MenuId('AccessibleView');
static readonly MultiDiffEditorContent = new MenuId('MultiDiffEditorContent');
static readonly MultiDiffEditorFileToolbar = new MenuId('MultiDiffEditorFileToolbar');
static readonly DiffEditorHunkToolbar = new MenuId('DiffEditorHunkToolbar');
static readonly DiffEditorSelectionToolbar = new MenuId('DiffEditorSelectionToolbar');
@@ -287,11 +289,11 @@ export class MenuId {
static readonly AgentSessionsCreateSubMenu = new MenuId('AgentSessionsCreateSubMenu');
static readonly AgentSessionsToolbar = new MenuId('AgentSessionsToolbar');
static readonly AgentSessionItemToolbar = new MenuId('AgentSessionItemToolbar');
static readonly ChatViewSessionTitleToolbar = new MenuId('ChatViewSessionTitleToolbar');
/**
* @deprecated TODO@bpasero remove both
* @deprecated TODO@bpasero remove
*/
static readonly ChatViewSessionTitleToolbar = new MenuId('ChatViewSessionTitleToolbar');
static readonly AgentSessionsViewTitle = new MenuId('AgentSessionsViewTitle');
/**
@@ -445,8 +445,8 @@ export function sortExtensionVersions(versions: IRawGalleryExtensionVersion[], p
export function filterLatestExtensionVersionsForTargetPlatform(versions: IRawGalleryExtensionVersion[], targetPlatform: TargetPlatform, allTargetPlatforms: TargetPlatform[]): IRawGalleryExtensionVersion[] {
const latestVersions: IRawGalleryExtensionVersion[] = [];
let preReleaseVersionFoundForTargetPlatform: boolean = false;
let releaseVersionFoundForTargetPlatform: boolean = false;
let preReleaseVersionIndex: number = -1;
let releaseVersionIndex: number = -1;
for (const version of versions) {
const versionTargetPlatform = getTargetPlatformForExtensionVersion(version);
const isCompatibleWithTargetPlatform = isTargetPlatformCompatible(versionTargetPlatform, allTargetPlatforms, targetPlatform);
@@ -458,15 +458,20 @@ export function filterLatestExtensionVersionsForTargetPlatform(versions: IRawGal
}
// For compatible versions, only include the first (latest) of each type
// Prefer specific target platform matches over undefined/universal platforms
if (isPreReleaseVersion(version)) {
if (!preReleaseVersionFoundForTargetPlatform) {
preReleaseVersionFoundForTargetPlatform = true;
if (preReleaseVersionIndex === -1) {
preReleaseVersionIndex = latestVersions.length;
latestVersions.push(version);
} else if (versionTargetPlatform === targetPlatform) {
latestVersions[preReleaseVersionIndex] = version;
}
} else {
if (!releaseVersionFoundForTargetPlatform) {
releaseVersionFoundForTargetPlatform = true;
if (releaseVersionIndex === -1) {
releaseVersionIndex = latestVersions.length;
latestVersions.push(version);
} else if (versionTargetPlatform === targetPlatform) {
latestVersions[releaseVersionIndex] = version;
}
}
}
@@ -133,17 +133,19 @@ suite('Extension Gallery Service', () => {
assert.deepStrictEqual(result, versions);
});
test('should filter out duplicate target platforms for release versions', () => {
test('should include both release and pre-release versions for same platform', () => {
const version1 = aExtensionVersion('1.0.0', TargetPlatform.WIN32_X64);
const version2 = aExtensionVersion('0.9.0', TargetPlatform.WIN32_X64); // Same platform, older version
const version2 = aPreReleaseExtensionVersion('0.9.0', TargetPlatform.WIN32_X64); // Different version number
const versions = [version1, version2];
const allTargetPlatforms = [TargetPlatform.WIN32_X64];
const result = filterLatestExtensionVersionsForTargetPlatform(versions, TargetPlatform.WIN32_X64, allTargetPlatforms);
// Should only include the first version (latest) for this platform
assert.strictEqual(result.length, 1);
// Should include both since they have different version numbers
assert.strictEqual(result.length, 2);
assert.strictEqual(result[0], version1);
assert.strictEqual(result[1], version2);
});
test('should include one version per target platform for release versions', () => {
@@ -176,17 +178,18 @@ suite('Extension Gallery Service', () => {
assert.ok(result.includes(preReleaseVersion));
});
test('should filter duplicate pre-release versions by target platform', () => {
test('should include both release and pre-release versions for same platform with different version numbers', () => {
const preRelease1 = aPreReleaseExtensionVersion('1.1.0', TargetPlatform.WIN32_X64);
const preRelease2 = aPreReleaseExtensionVersion('1.0.0', TargetPlatform.WIN32_X64); // Same platform, older
const versions = [preRelease1, preRelease2];
const release2 = aExtensionVersion('1.0.0', TargetPlatform.WIN32_X64); // Different version number
const versions = [preRelease1, release2];
const allTargetPlatforms = [TargetPlatform.WIN32_X64];
const result = filterLatestExtensionVersionsForTargetPlatform(versions, TargetPlatform.WIN32_X64, allTargetPlatforms);
// Should only include the first pre-release version for this platform
assert.strictEqual(result.length, 1);
// Should include both since they have different version numbers
assert.strictEqual(result.length, 2);
assert.strictEqual(result[0], preRelease1);
assert.strictEqual(result[1], release2);
});
test('should handle versions without target platform (UNDEFINED)', () => {
@@ -207,9 +210,8 @@ suite('Extension Gallery Service', () => {
const releaseMac = aExtensionVersion('1.0.0', TargetPlatform.DARWIN_X64);
const preReleaseWin = aPreReleaseExtensionVersion('1.1.0', TargetPlatform.WIN32_X64);
const preReleaseMac = aPreReleaseExtensionVersion('1.1.0', TargetPlatform.DARWIN_X64);
const oldReleaseWin = aExtensionVersion('0.9.0', TargetPlatform.WIN32_X64); // Should be filtered out
const versions = [releaseWin, releaseMac, preReleaseWin, preReleaseMac, oldReleaseWin];
const versions = [releaseWin, releaseMac, preReleaseWin, preReleaseMac];
const allTargetPlatforms = [TargetPlatform.WIN32_X64, TargetPlatform.DARWIN_X64];
const result = filterLatestExtensionVersionsForTargetPlatform(versions, TargetPlatform.WIN32_X64, allTargetPlatforms);
@@ -220,16 +222,13 @@ suite('Extension Gallery Service', () => {
assert.ok(result.includes(releaseMac)); // Non-compatible, included
assert.ok(result.includes(preReleaseWin)); // Compatible pre-release
assert.ok(result.includes(preReleaseMac)); // Non-compatible, included
assert.ok(!result.includes(oldReleaseWin)); // Filtered (older compatible release)
});
test('should handle complex scenario with multiple versions and platforms', () => {
const versions = [
aExtensionVersion('2.0.0', TargetPlatform.WIN32_X64),
aExtensionVersion('2.0.0', TargetPlatform.DARWIN_X64),
aExtensionVersion('1.9.0', TargetPlatform.WIN32_X64), // Older release, same platform
aPreReleaseExtensionVersion('2.1.0', TargetPlatform.WIN32_X64),
aPreReleaseExtensionVersion('2.0.5', TargetPlatform.WIN32_X64), // Older pre-release, same platform
aPreReleaseExtensionVersion('2.1.0', TargetPlatform.LINUX_X64),
aExtensionVersion('2.0.0'), // No platform specified
aPreReleaseExtensionVersion('2.1.0'), // Pre-release, no platform specified
@@ -239,19 +238,19 @@ suite('Extension Gallery Service', () => {
const result = filterLatestExtensionVersionsForTargetPlatform(versions, TargetPlatform.WIN32_X64, allTargetPlatforms);
// Expected for WIN32_X64 target platform:
// - Compatible (WIN32_X64 + UNDEFINED): Only first release and first pre-release
// - Compatible (WIN32_X64 + UNDEFINED): release (2.0.0 WIN32_X64) and pre-release (2.1.0 WIN32_X64)
// - Non-compatible: DARWIN_X64 release, LINUX_X64 pre-release
// Total: 4 versions (1 compatible release + 1 compatible pre-release + 2 non-compatible)
assert.strictEqual(result.length, 4);
// Check specific versions are included
assert.ok(result.includes(versions[0])); // 2.0.0 WIN32_X64 (first compatible release)
assert.ok(result.includes(versions[0])); // 2.0.0 WIN32_X64 (compatible release)
assert.ok(result.includes(versions[1])); // 2.0.0 DARWIN_X64 (non-compatible)
assert.ok(result.includes(versions[3])); // 2.1.0 WIN32_X64 (first compatible pre-release)
assert.ok(result.includes(versions[5])); // 2.1.0 LINUX_X64 (non-compatible)
assert.ok(result.includes(versions[2])); // 2.1.0 WIN32_X64 (compatible pre-release)
assert.ok(result.includes(versions[3])); // 2.1.0 LINUX_X64 (non-compatible)
});
test('should handle UNDEFINED platform interaction with specific platforms', () => {
test('should keep only first compatible version when specific platform comes before undefined', () => {
// Test how UNDEFINED platform interacts with specific platforms
const versions = [
aExtensionVersion('1.0.0', TargetPlatform.WIN32_X64),
@@ -261,10 +260,9 @@ suite('Extension Gallery Service', () => {
const result = filterLatestExtensionVersionsForTargetPlatform(versions, TargetPlatform.WIN32_X64, allTargetPlatforms);
// Both are compatible with WIN32_X64, but only the first of each type should be included
// Since both are release versions, only the first one should be included
// Both are compatible with WIN32_X64, first one should be included (specific platform preferred)
assert.strictEqual(result.length, 1);
assert.ok(result.includes(versions[0])); // WIN32_X64 should be included (first release)
assert.ok(result.includes(versions[0])); // WIN32_X64 should be included (specific platform)
});
test('should handle higher version with specific platform vs lower version with universal platform', () => {
@@ -305,23 +303,17 @@ suite('Extension Gallery Service', () => {
aExtensionVersion('2.0.0', TargetPlatform.WIN32_X64), // Highest version, specific platform
aExtensionVersion('1.9.0', TargetPlatform.DARWIN_X64), // Lower version, different specific platform
aExtensionVersion('1.8.0'), // Lowest version, universal platform
aExtensionVersion('1.7.0', TargetPlatform.WIN32_X64), // Even older, same platform as first - should be filtered
];
const allTargetPlatforms = [TargetPlatform.WIN32_X64, TargetPlatform.DARWIN_X64, TargetPlatform.LINUX_X64];
const result = filterLatestExtensionVersionsForTargetPlatform(versions, TargetPlatform.WIN32_X64, allTargetPlatforms);
// Should include:
// - 2.0.0 WIN32_X64 (first compatible release for WIN32_X64)
// - 2.0.0 WIN32_X64 (specific target platform match - replaces UNDEFINED if it came first)
// - 1.9.0 DARWIN_X64 (non-compatible, included)
// - 1.8.0 UNDEFINED (second compatible release, filtered)
// Should NOT include:
// - 1.7.0 WIN32_X64 (third compatible release, filtered)
assert.strictEqual(result.length, 2);
assert.ok(result.includes(versions[0])); // 2.0.0 WIN32_X64
assert.ok(result.includes(versions[1])); // 1.9.0 DARWIN_X64
assert.ok(!result.includes(versions[2])); // 1.8.0 UNDEFINED should be filtered
assert.ok(!result.includes(versions[3])); // 1.7.0 WIN32_X64 should be filtered
});
test('should include universal platform when no specific platforms conflict', () => {
@@ -341,7 +333,7 @@ suite('Extension Gallery Service', () => {
assert.ok(result.includes(specificVersion)); // Non-compatible, included
});
test('should preserve order of input when no filtering occurs', () => {
test('should include all non-compatible platform versions', () => {
const version1 = aExtensionVersion('1.0.0', TargetPlatform.WIN32_X64);
const version2 = aExtensionVersion('1.0.0', TargetPlatform.DARWIN_X64);
const version3 = aPreReleaseExtensionVersion('1.1.0', TargetPlatform.LINUX_X64);
@@ -350,12 +342,106 @@ suite('Extension Gallery Service', () => {
const result = filterLatestExtensionVersionsForTargetPlatform(versions, TargetPlatform.WIN32_X64, allTargetPlatforms);
// For WIN32_X64 target: version1 (compatible release) + version2, version3 (non-compatible)
assert.strictEqual(result.length, 3);
assert.ok(result.includes(version1)); // Compatible release
assert.ok(result.includes(version2)); // Non-compatible, included
assert.ok(result.includes(version3)); // Non-compatible, included
});
test('should prefer specific target platform over undefined when same version exists for both', () => {
const undefinedVersion = aExtensionVersion('1.0.0'); // UNDEFINED platform, appears first
const specificVersion = aExtensionVersion('1.0.0', TargetPlatform.WIN32_X64); // Specific platform, appears second
const versions = [undefinedVersion, specificVersion];
const allTargetPlatforms = [TargetPlatform.WIN32_X64];
const result = filterLatestExtensionVersionsForTargetPlatform(versions, TargetPlatform.WIN32_X64, allTargetPlatforms);
// Should return the specific platform version (WIN32_X64), not the undefined one
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0], specificVersion);
assert.ok(!result.includes(undefinedVersion));
});
test('should replace undefined pre-release with specific platform pre-release', () => {
const undefinedPreRelease = aPreReleaseExtensionVersion('1.0.0'); // UNDEFINED platform pre-release, appears first
const specificPreRelease = aPreReleaseExtensionVersion('1.0.0', TargetPlatform.WIN32_X64); // Specific platform pre-release, appears second
const versions = [undefinedPreRelease, specificPreRelease];
const allTargetPlatforms = [TargetPlatform.WIN32_X64];
const result = filterLatestExtensionVersionsForTargetPlatform(versions, TargetPlatform.WIN32_X64, allTargetPlatforms);
// Should return the specific platform pre-release, not the undefined one
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0], specificPreRelease);
assert.ok(!result.includes(undefinedPreRelease));
});
test('should handle explicit UNIVERSAL platform', () => {
const universalVersion = aExtensionVersion('1.0.0', TargetPlatform.UNIVERSAL);
const specificVersion = aExtensionVersion('1.0.0', TargetPlatform.WIN32_X64);
const versions = [universalVersion, specificVersion];
const allTargetPlatforms = [TargetPlatform.WIN32_X64];
const result = filterLatestExtensionVersionsForTargetPlatform(versions, TargetPlatform.WIN32_X64, allTargetPlatforms);
// Should return the specific platform version, not the universal one
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0], specificVersion);
assert.ok(!result.includes(universalVersion));
});
test('should handle both release and pre-release with replacement', () => {
// Both release and pre-release starting with undefined and then getting specific platform
const undefinedRelease = aExtensionVersion('1.0.0'); // UNDEFINED release
const specificRelease = aExtensionVersion('1.0.0', TargetPlatform.WIN32_X64); // Specific release
const undefinedPreRelease = aPreReleaseExtensionVersion('1.1.0'); // UNDEFINED pre-release
const specificPreRelease = aPreReleaseExtensionVersion('1.1.0', TargetPlatform.WIN32_X64); // Specific pre-release
const versions = [undefinedRelease, undefinedPreRelease, specificRelease, specificPreRelease];
const allTargetPlatforms = [TargetPlatform.WIN32_X64];
const result = filterLatestExtensionVersionsForTargetPlatform(versions, TargetPlatform.WIN32_X64, allTargetPlatforms);
// Should return both specific platform versions
assert.strictEqual(result.length, 2);
assert.ok(result.includes(specificRelease));
assert.ok(result.includes(specificPreRelease));
assert.ok(!result.includes(undefinedRelease));
assert.ok(!result.includes(undefinedPreRelease));
});
test('should not replace when specific platform is for different platform', () => {
const undefinedVersion = aExtensionVersion('1.0.0'); // UNDEFINED, compatible with WIN32_X64
const specificVersionDarwin = aExtensionVersion('1.0.0', TargetPlatform.DARWIN_X64); // Specific for DARWIN, not compatible with WIN32_X64
const versions = [undefinedVersion, specificVersionDarwin];
const allTargetPlatforms = [TargetPlatform.WIN32_X64, TargetPlatform.DARWIN_X64];
const result = filterLatestExtensionVersionsForTargetPlatform(versions, TargetPlatform.WIN32_X64, allTargetPlatforms);
// Should return undefined version (compatible with WIN32_X64) and specific DARWIN version (non-compatible, always included)
assert.strictEqual(result.length, 2);
assert.ok(result.includes(undefinedVersion));
assert.ok(result.includes(specificVersionDarwin));
});
test('should handle replacement with non-compatible versions in between', () => {
const undefinedVersion = aExtensionVersion('1.0.0'); // UNDEFINED, compatible with WIN32_X64
const nonCompatibleVersion = aExtensionVersion('0.9.0', TargetPlatform.LINUX_ARM64); // Non-compatible platform
const specificVersion = aExtensionVersion('1.0.0', TargetPlatform.WIN32_X64); // Specific for WIN32_X64
const versions = [undefinedVersion, nonCompatibleVersion, specificVersion];
const allTargetPlatforms = [TargetPlatform.WIN32_X64, TargetPlatform.DARWIN_X64];
const result = filterLatestExtensionVersionsForTargetPlatform(versions, TargetPlatform.WIN32_X64, allTargetPlatforms);
// Should return specific WIN32_X64 version (replacing undefined) and non-compatible LINUX_ARM64 version
assert.strictEqual(result.length, 2);
assert.ok(result.includes(specificVersion));
assert.ok(result.includes(nonCompatibleVersion));
assert.ok(!result.includes(undefinedVersion));
});
});
});
@@ -468,7 +468,6 @@ export abstract class QuickInput extends Disposable implements IQuickInput {
// Adjust count badge position based on number of toggles (each toggle is ~22px wide)
const toggleOffset = concreteToggles.length * 22;
this.ui.countContainer.style.right = toggleOffset > 0 ? `${4 + toggleOffset}px` : '4px';
this.ui.visibleCountContainer.style.right = toggleOffset > 0 ? `${4 + toggleOffset}px` : '4px';
}
this.ui.ignoreFocusOut = this.ignoreFocusOut;
this.ui.setEnabled(this.enabled);
@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { BrowserWindow, BrowserWindowConstructorOptions, Event } from 'electron';
import type { BeforeSendResponse, BrowserWindow, BrowserWindowConstructorOptions, Event, OnBeforeSendHeadersListenerDetails } from 'electron';
import { Queue, raceTimeout, TimeoutTimer } from '../../../base/common/async.js';
import { createSingleCallFunction } from '../../../base/common/functional.js';
import { Disposable, toDisposable } from '../../../base/common/lifecycle.js';
@@ -29,6 +29,7 @@ export class WebPageLoader extends Disposable {
private static readonly POST_LOAD_TIMEOUT = 5000; // 5 seconds - increased for dynamic content
private static readonly FRAME_TIMEOUT = 500; // 0.5 seconds
private static readonly IDLE_DEBOUNCE_TIME = 500; // 0.5 seconds - wait after last network request
private static readonly MIN_CONTENT_LENGTH = 100; // Minimum content length to consider extraction successful
private readonly _window: BrowserWindow;
private readonly _debugger: Electron.Debugger;
@@ -71,7 +72,11 @@ export class WebPageLoader extends Disposable {
.once('did-finish-load', this.onFinishLoad.bind(this))
.once('did-fail-load', this.onFailLoad.bind(this))
.once('will-navigate', this.onRedirect.bind(this))
.once('will-redirect', this.onRedirect.bind(this));
.once('will-redirect', this.onRedirect.bind(this))
.on('select-client-certificate', (event) => event.preventDefault());
this._window.webContents.session.webRequest.onBeforeSendHeaders(
this.onBeforeSendHeaders.bind(this));
}
private trace(message: string) {
@@ -126,6 +131,19 @@ export class WebPageLoader extends Disposable {
}, time);
}
/**
* Updates HTTP headers for each web request.
*/
private onBeforeSendHeaders(details: OnBeforeSendHeadersListenerDetails, callback: (beforeSendResponse: BeforeSendResponse) => void) {
const headers = { ...details.requestHeaders };
// Request privacy for web-sites that respect these.
headers['DNT'] = '1';
headers['Sec-GPC'] = '1';
callback({ requestHeaders: headers });
}
/**
* Handles the 'did-start-loading' event, enabling network tracking.
*/
@@ -164,7 +182,12 @@ export class WebPageLoader extends Disposable {
}
this.trace(`Received 'did-fail-load' event, code: ${statusCode}, error: '${error}'`);
void this._queue.queue(() => this.extractContent({ status: 'error', statusCode, error }));
if (statusCode === -3) {
this.trace(`Ignoring ERR_ABORTED (-3) as it may be caused by CSP or other measures`);
void this._queue.queue(() => this.extractContent());
} else {
void this._queue.queue(() => this.extractContent({ status: 'error', statusCode, error }));
}
}
/**
@@ -287,12 +310,18 @@ export class WebPageLoader extends Disposable {
}
try {
this.trace(`Extracting content using Accessibility domain`);
const title = this._window.webContents.getTitle();
const { nodes } = await this._debugger.sendCommand('Accessibility.getFullAXTree') as { nodes: AXNode[] };
const result = convertAXTreeToMarkdown(this._uri, nodes);
if (errorResult !== undefined) {
let result = await this.extractAccessibilityTreeContent() ?? '';
if (result.length < WebPageLoader.MIN_CONTENT_LENGTH) {
this.trace(`Accessibility tree extraction yielded insufficient content, trying main DOM element extraction`);
const domContent = await this.extractMainDomElementContent() ?? '';
result = domContent.length > result.length ? domContent : result;
}
if (result.length === 0) {
this._onResult({ status: 'error', error: 'Failed to extract meaningful content from the web page' });
} else if (errorResult !== undefined) {
this._onResult({ ...errorResult, result, title });
} else {
this._onResult({ status: 'ok', result, title });
@@ -308,4 +337,45 @@ export class WebPageLoader extends Disposable {
}
}
}
/**
* Extracts content from the Accessibility tree of the loaded web page.
* @return The extracted content, or undefined if extraction fails.
*/
private async extractAccessibilityTreeContent(): Promise<string | undefined> {
this.trace(`Extracting content using Accessibility domain`);
try {
const { nodes } = await this._debugger.sendCommand('Accessibility.getFullAXTree') as { nodes: AXNode[] };
return convertAXTreeToMarkdown(this._uri, nodes);
} catch (error) {
this.trace(`Accessibility tree extraction failed: ${error instanceof Error ? error.message : String(error)}`);
return undefined;
}
}
/**
* Fallback method for extracting web page content when Accessibility tree extraction yields insufficient content.
* Attempts to extract meaningful text content from the main DOM elements of the loaded web page.
* @returns The extracted text content, or undefined if extraction fails.
*/
private async extractMainDomElementContent(): Promise<string | undefined> {
try {
this.trace(`Extracting content from main DOM element`);
return await this._window.webContents.executeJavaScript(`
(() => {
const selectors = ['main','article','[role="main"]','.main-content','#main-content','.article-body','.post-content','.entry-content','.content','body'];
for (const selector of selectors) {
const content = document.querySelector(selector)?.textContent?.replace(/[ \\t]+/g, ' ').replace(/\\s{2,}/gm, '\\n').trim();
if (content && content.length > ${WebPageLoader.MIN_CONTENT_LENGTH}) {
return content;
}
}
return undefined;
})();
`);
} catch (error) {
this.trace(`DOM extraction failed: ${error instanceof Error ? error.message : String(error)}`);
return undefined;
}
}
}
@@ -21,6 +21,13 @@ class MockWebContents {
public readonly debugger: MockDebugger;
public loadURL = sinon.stub().resolves();
public getTitle = sinon.stub().returns('Test Page Title');
public executeJavaScript = sinon.stub().resolves(undefined);
public session = {
webRequest: {
onBeforeSendHeaders: sinon.stub()
}
};
constructor() {
this.debugger = new MockDebugger();
@@ -34,6 +41,14 @@ class MockWebContents {
return this;
}
on(event: string, listener: (...args: unknown[]) => void): this {
if (!this._listeners.has(event)) {
this._listeners.set(event, []);
}
this._listeners.get(event)!.push(listener);
return this;
}
emit(event: string, ...args: unknown[]): void {
const listeners = this._listeners.get(event) || [];
for (const listener of listeners) {
@@ -179,6 +194,38 @@ suite('WebPageLoader', () => {
}
});
test('ERR_ABORTED is ignored and content extraction continues', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
const uri = URI.parse('https://example.com/page');
const axNodes = createMockAXNodes();
const loader = createWebPageLoader(uri);
window.webContents.debugger.sendCommand.callsFake((command: string) => {
switch (command) {
case 'Network.enable':
return Promise.resolve();
case 'Accessibility.getFullAXTree':
return Promise.resolve({ nodes: axNodes });
default:
assert.fail(`Unexpected command: ${command}`);
}
});
const loadPromise = loader.load();
// Simulate ERR_ABORTED (-3) which should be ignored
const mockEvent: MockElectronEvent = {};
window.webContents.emit('did-fail-load', mockEvent, -3, 'ERR_ABORTED');
const result = await loadPromise;
// ERR_ABORTED should not cause an error status, content should be extracted
assert.strictEqual(result.status, 'ok');
if (result.status === 'ok') {
assert.ok(result.result.includes('Test content from page'));
}
}));
//#endregion
//#region Redirect Tests
@@ -540,8 +587,17 @@ suite('WebPageLoader', () => {
}
}));
test('handles empty accessibility tree', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
const uri = URI.parse('https://example.com/empty');
test('falls back to DOM extraction when accessibility tree yields insufficient content', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
const uri = URI.parse('https://example.com/page');
// Create AX tree with very short content (less than MIN_CONTENT_LENGTH)
const shortAXNodes: AXNode[] = [
{
nodeId: 'node1',
ignored: false,
role: { type: 'role', value: 'StaticText' },
name: { type: 'string', value: 'Short' }
}
];
const loader = createWebPageLoader(uri);
@@ -550,12 +606,16 @@ suite('WebPageLoader', () => {
case 'Network.enable':
return Promise.resolve();
case 'Accessibility.getFullAXTree':
return Promise.resolve({ nodes: [] });
return Promise.resolve({ nodes: shortAXNodes });
default:
assert.fail(`Unexpected command: ${command}`);
}
});
// Mock DOM extraction returning longer content
const domContent = 'This is much longer content extracted from the DOM that exceeds the minimum content length requirement and should be used instead of the short accessibility tree content.';
window.webContents.executeJavaScript.resolves(domContent);
const loadPromise = loader.load();
window.webContents.emit('did-start-loading');
@@ -565,12 +625,14 @@ suite('WebPageLoader', () => {
assert.strictEqual(result.status, 'ok');
if (result.status === 'ok') {
assert.strictEqual(result.result, '');
assert.strictEqual(result.result, domContent);
}
// Verify executeJavaScript was called for DOM extraction
assert.ok(window.webContents.executeJavaScript.called);
}));
test('handles accessibility extraction failure', async () => {
const uri = URI.parse('https://example.com/page');
test('returns error when both accessibility tree and DOM extraction yield no content', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
const uri = URI.parse('https://example.com/empty-page');
const loader = createWebPageLoader(uri);
@@ -579,12 +641,16 @@ suite('WebPageLoader', () => {
case 'Network.enable':
return Promise.resolve();
case 'Accessibility.getFullAXTree':
return Promise.reject(new Error('Debugger detached'));
// Return empty accessibility tree
return Promise.resolve({ nodes: [] });
default:
assert.fail(`Unexpected command: ${command}`);
}
});
// Mock DOM extraction returning undefined (no content)
window.webContents.executeJavaScript.resolves(undefined);
const loadPromise = loader.load();
window.webContents.emit('did-start-loading');
@@ -594,8 +660,45 @@ suite('WebPageLoader', () => {
assert.strictEqual(result.status, 'error');
if (result.status === 'error') {
assert.ok(result.error.includes('Debugger detached'));
assert.ok(result.error.includes('Failed to extract meaningful content'));
}
// Verify both extraction methods were attempted
assert.ok(window.webContents.executeJavaScript.called);
}));
//#endregion
//#region Header Modification Tests
test('onBeforeSendHeaders adds browser headers for navigation', () => {
createWebPageLoader(URI.parse('https://example.com/page'));
// Get the callback passed to onBeforeSendHeaders
assert.ok(window.webContents.session.webRequest.onBeforeSendHeaders.called);
const callback = window.webContents.session.webRequest.onBeforeSendHeaders.getCall(0).args[0];
// Mock callback function
let modifiedHeaders: Record<string, string> | undefined;
const mockCallback = (details: { requestHeaders: Record<string, string> }) => {
modifiedHeaders = details.requestHeaders;
};
// Simulate a request to the same domain
callback(
{
url: 'https://example.com/page',
requestHeaders: {
'TestHeader': 'TestValue'
}
},
mockCallback
);
// Verify headers were added
assert.ok(modifiedHeaders);
assert.strictEqual(modifiedHeaders['DNT'], '1');
assert.strictEqual(modifiedHeaders['Sec-GPC'], '1');
assert.strictEqual(modifiedHeaders['TestHeader'], 'TestValue');
});
//#endregion
@@ -5,7 +5,7 @@
import { raceCancellationError } from '../../../base/common/async.js';
import { CancellationToken } from '../../../base/common/cancellation.js';
import { Emitter } from '../../../base/common/event.js';
import { Emitter, Event } from '../../../base/common/event.js';
import { IMarkdownString, MarkdownString } from '../../../base/common/htmlContent.js';
import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js';
import { ResourceMap } from '../../../base/common/map.js';
@@ -16,8 +16,10 @@ import { URI, UriComponents } from '../../../base/common/uri.js';
import { localize } from '../../../nls.js';
import { IDialogService } from '../../../platform/dialogs/common/dialogs.js';
import { ILogService } from '../../../platform/log/common/log.js';
import { ChatViewPaneTarget, IChatWidgetService, isIChatViewViewContext } from '../../contrib/chat/browser/chat.js';
import { IChatEditorOptions } from '../../contrib/chat/browser/chatEditor.js';
import { ChatEditorInput } from '../../contrib/chat/browser/chatEditorInput.js';
import { awaitStatsForSession } from '../../contrib/chat/common/chat.js';
import { IChatAgentRequest } from '../../contrib/chat/common/chatAgents.js';
import { IChatContentInlineReference, IChatProgress, IChatService } from '../../contrib/chat/common/chatService.js';
import { IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionItemProvider, IChatSessionProviderOptionItem, IChatSessionsService } from '../../contrib/chat/common/chatSessionsService.js';
@@ -331,6 +333,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat
private readonly _extHostContext: IExtHostContext,
@IChatSessionsService private readonly _chatSessionsService: IChatSessionsService,
@IChatService private readonly _chatService: IChatService,
@IChatWidgetService private readonly _chatWidgetService: IChatWidgetService,
@IDialogService private readonly _dialogService: IDialogService,
@IEditorService private readonly _editorService: IEditorService,
@IEditorGroupsService private readonly editorGroupService: IEditorGroupsService,
@@ -340,7 +343,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat
this._proxy = this._extHostContext.getProxy(ExtHostContext.ExtHostChatSessions);
this._chatSessionsService.setOptionsChangeCallback(async (sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string }>) => {
this._chatSessionsService.setOptionsChangeCallback(async (sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>) => {
const handle = this._getHandleForSessionType(sessionResource.scheme);
if (handle !== undefined) {
await this.notifyOptionsChange(handle, sessionResource, updates);
@@ -358,7 +361,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat
const changeEmitter = disposables.add(new Emitter<void>());
const provider: IChatSessionItemProvider = {
chatSessionType,
onDidChangeChatSessionItems: changeEmitter.event,
onDidChangeChatSessionItems: Event.debounce(changeEmitter.event, (_, e) => e, 200),
provideChatSessionItems: (token) => this._provideChatSessionItems(handle, token),
provideNewChatSessionItem: (options, token) => this._provideNewChatSessionItem(handle, options, token)
};
@@ -432,6 +435,19 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat
options,
},
}], originalGroup);
return;
}
const chatViewWidget = this._chatWidgetService.getWidgetBySessionResource(originalResource);
if (chatViewWidget && isIChatViewViewContext(chatViewWidget.viewContext)) {
const newSession = await this._chatSessionsService.getOrCreateChatSession(modifiedResource, CancellationToken.None);
// If chat editor is in the side panel, then those are not listed as editors.
// In that case we need to transfer editing session using the original model.
const originalModel = this._chatService.getSession(originalResource);
if (originalModel) {
newSession.initialEditingSession = originalModel.editingSession;
}
await this._chatWidgetService.openSession(modifiedResource, ChatViewPaneTarget, { preserveFocus: true });
}
}
@@ -439,21 +455,39 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat
try {
// Get all results as an array from the RPC call
const sessions = await this._proxy.$provideChatSessionItems(handle, token);
return sessions.map(session => {
return Promise.all(sessions.map(async session => {
const uri = URI.revive(session.resource);
const model = this._chatService.getSession(uri);
let description: string | undefined;
let changes: IChatSessionItem['changes'];
if (model) {
description = this._chatSessionsService.getSessionDescription(model);
}
if (session.changes instanceof Array) {
changes = revive(session.changes);
} else {
const modelStats = model ?
await awaitStatsForSession(model) :
(await this._chatService.getMetadataForSession(uri))?.stats;
if (modelStats) {
changes = {
files: modelStats.fileCount,
insertions: modelStats.added,
deletions: modelStats.removed
};
}
}
return {
...session,
changes,
resource: uri,
iconPath: session.iconPath,
tooltip: session.tooltip ? this._reviveTooltip(session.tooltip) : undefined,
description: description || session.description
};
});
} satisfies IChatSessionItem;
}));
} catch (error) {
this._logService.error('Error providing chat sessions:', error);
}
@@ -468,6 +502,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat
}
return {
...chatSessionItem,
changes: revive(chatSessionItem.changes),
resource: URI.revive(chatSessionItem.resource),
iconPath: chatSessionItem.iconPath,
tooltip: chatSessionItem.tooltip ? this._reviveTooltip(chatSessionItem.tooltip) : undefined,
@@ -620,7 +655,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat
/**
* Notify the extension about option changes for a session
*/
async notifyOptionsChange(handle: number, sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | undefined }>): Promise<void> {
async notifyOptionsChange(handle: number, sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem | undefined }>): Promise<void> {
try {
await this._proxy.$provideHandleOptionsChange(handle, sessionResource, updates, CancellationToken.None);
} catch (error) {
@@ -1864,6 +1864,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
EditSessionIdentityMatch: EditSessionIdentityMatch,
InteractiveSessionVoteDirection: extHostTypes.InteractiveSessionVoteDirection,
ChatCopyKind: extHostTypes.ChatCopyKind,
ChatSessionChangedFile: extHostTypes.ChatSessionChangedFile,
ChatEditingSessionActionOutcome: extHostTypes.ChatEditingSessionActionOutcome,
InteractiveEditorResponseFeedbackKind: extHostTypes.InteractiveEditorResponseFeedbackKind,
DebugStackFrame: extHostTypes.DebugStackFrame,
@@ -3275,12 +3275,12 @@ export type IChatSessionHistoryItemDto = {
export interface ChatSessionOptionUpdateDto {
readonly optionId: string;
readonly value: string | undefined;
readonly value: string | IChatSessionProviderOptionItem | undefined;
}
export interface ChatSessionOptionUpdateDto2 {
readonly optionId: string;
readonly value: string;
readonly value: string | IChatSessionProviderOptionItem;
}
export interface ChatSessionDto {
@@ -15,7 +15,7 @@ import { URI, UriComponents } from '../../../base/common/uri.js';
import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js';
import { ILogService } from '../../../platform/log/common/log.js';
import { IChatAgentRequest, IChatAgentResult } from '../../contrib/chat/common/chatAgents.js';
import { ChatSessionStatus, IChatSessionItem } from '../../contrib/chat/common/chatSessionsService.js';
import { ChatSessionStatus, IChatSessionItem, IChatSessionProviderOptionItem } from '../../contrib/chat/common/chatSessionsService.js';
import { ChatAgentLocation } from '../../contrib/chat/common/constants.js';
import { Proxied } from '../../services/extensions/common/proxyIdentifier.js';
import { ChatSessionDto, ExtHostChatSessionsShape, IChatAgentProgressShape, IChatSessionProviderOptions, MainContext, MainThreadChatSessionsShape } from './extHost.protocol.js';
@@ -186,11 +186,13 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio
startTime: sessionContent.timing?.startTime ?? 0,
endTime: sessionContent.timing?.endTime
},
statistics: sessionContent.statistics ? {
files: sessionContent.statistics?.files ?? 0,
insertions: sessionContent.statistics?.insertions ?? 0,
deletions: sessionContent.statistics?.deletions ?? 0
} : undefined
changes: sessionContent.changes instanceof Array
? sessionContent.changes :
(sessionContent.changes && {
files: sessionContent.changes?.files ?? 0,
insertions: sessionContent.changes?.insertions ?? 0,
deletions: sessionContent.changes?.deletions ?? 0,
}),
};
}
@@ -309,7 +311,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio
};
}
async $provideHandleOptionsChange(handle: number, sessionResourceComponents: UriComponents, updates: ReadonlyArray<{ optionId: string; value: string | undefined }>, token: CancellationToken): Promise<void> {
async $provideHandleOptionsChange(handle: number, sessionResourceComponents: UriComponents, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem | undefined }>, token: CancellationToken): Promise<void> {
const sessionResource = URI.revive(sessionResourceComponents);
const provider = this._chatSessionContentProviders.get(handle);
if (!provider) {
@@ -323,7 +325,11 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio
}
try {
await provider.provider.provideHandleOptionsChange(sessionResource, updates, token);
const updatesToSend = updates.map(update => ({
optionId: update.optionId,
value: update.value === undefined ? undefined : (typeof update.value === 'string' ? update.value : update.value.id)
}));
await provider.provider.provideHandleOptionsChange(sessionResource, updatesToSend, token);
} catch (error) {
this._logService.error(`Error calling provideHandleOptionsChange for handle ${handle}, sessionResource ${sessionResource}:`, error);
}
+26 -16
View File
@@ -314,6 +314,7 @@ class ExtHostTreeView<T> extends Disposable {
private static readonly LABEL_HANDLE_PREFIX = '0';
private static readonly ID_HANDLE_PREFIX = '1';
private static readonly ROOT_FETCH_KEY = Symbol('extHostTreeViewRoot');
private readonly _dataProvider: vscode.TreeDataProvider<T>;
private readonly _dndController: vscode.TreeDragAndDropController<T> | undefined;
@@ -321,6 +322,9 @@ class ExtHostTreeView<T> extends Disposable {
private _roots: TreeNode[] | undefined = undefined;
private _elements: Map<TreeItemHandle, T> = new Map<TreeItemHandle, T>();
private _nodes: Map<T, TreeNode> = new Map<T, TreeNode>();
// Track the latest child-fetch per element so that refresh-triggered cache clears ignore stale results.
// Without these tokens, an earlier getChildren promise resolving after refresh would re-register handles and hit the duplicate-id guard.
private readonly _childrenFetchTokens = new Map<T | typeof ExtHostTreeView.ROOT_FETCH_KEY, number>();
private _visible: boolean = false;
get visible(): boolean { return this._visible; }
@@ -725,14 +729,25 @@ class ExtHostTreeView<T> extends Disposable {
return this._roots;
}
private _getFetchKey(parentElement?: T): T | typeof ExtHostTreeView.ROOT_FETCH_KEY {
return parentElement ?? ExtHostTreeView.ROOT_FETCH_KEY;
}
private async _fetchChildrenNodes(parentElement?: T): Promise<TreeNode[] | undefined> {
// clear children cache
this._addChildrenToClear(parentElement);
const fetchKey = this._getFetchKey(parentElement);
let requestId = this._childrenFetchTokens.get(fetchKey) ?? 0;
requestId++;
this._childrenFetchTokens.set(fetchKey, requestId);
const cts = new CancellationTokenSource(this._refreshCancellationSource.token);
try {
const elements = await this._dataProvider.getChildren(parentElement);
if (this._childrenFetchTokens.get(fetchKey) !== requestId) {
return undefined;
}
const parentNode = parentElement ? this._nodes.get(parentElement) : undefined;
if (cts.token.isCancellationRequested) {
@@ -743,12 +758,18 @@ class ExtHostTreeView<T> extends Disposable {
const treeItems = await Promise.all(coalesce(coalescedElements).map(element => {
return this._dataProvider.getTreeItem(element);
}));
if (this._childrenFetchTokens.get(fetchKey) !== requestId) {
return undefined;
}
if (cts.token.isCancellationRequested) {
return undefined;
}
// createAndRegisterTreeNodes adds the nodes to a cache. This must be done sync so that they get added in the correct order.
const items = treeItems.map((item, index) => item ? this._createAndRegisterTreeNode(coalescedElements[index], item, parentNode) : null);
if (this._childrenFetchTokens.get(fetchKey) !== requestId) {
return undefined;
}
return coalesce(items);
} finally {
@@ -842,23 +863,10 @@ class ExtHostTreeView<T> extends Disposable {
}
private _createAndRegisterTreeNode(element: T, extTreeItem: vscode.TreeItem, parentNode: TreeNode | Root): TreeNode {
const duplicateHandle = extTreeItem.id ? `${ExtHostTreeView.ID_HANDLE_PREFIX}/${extTreeItem.id}` : undefined;
if (duplicateHandle) {
const existingElement = this._elements.get(duplicateHandle);
if (existingElement) {
if (existingElement !== element) {
throw new Error(localize('treeView.duplicateElement', 'Element with id {0} is already registered', extTreeItem.id));
}
const existingNode = this._nodes.get(existingElement);
if (existingNode) {
const newNode = this._createTreeNode(element, extTreeItem, parentNode);
this._updateNodeCache(element, newNode, existingNode, parentNode);
existingNode.dispose();
return newNode;
}
}
}
const node = this._createTreeNode(element, extTreeItem, parentNode);
if (extTreeItem.id && this._elements.has(node.item.handle)) {
throw new Error(localize('treeView.duplicateElement', 'Element with id {0} is already registered', extTreeItem.id));
}
this._addNodeToCache(element, node);
this._addNodeToParentCache(node, parentNode);
return node;
@@ -1062,6 +1070,7 @@ class ExtHostTreeView<T> extends Disposable {
});
this._nodes.clear();
this._elements.clear();
this._childrenFetchTokens.clear();
}
private _clearNodes(nodes: TreeNode[]): void {
@@ -1075,6 +1084,7 @@ class ExtHostTreeView<T> extends Disposable {
this._nodes.clear();
dispose(this._nodesToClear);
this._nodesToClear.clear();
this._childrenFetchTokens.clear();
}
override dispose() {
@@ -3425,6 +3425,10 @@ export enum ChatSessionStatus {
InProgress = 2
}
export class ChatSessionChangedFile {
constructor(public readonly modifiedUri: vscode.Uri, public readonly insertions: number, public readonly deletions: number, public readonly originalUri?: vscode.Uri) { }
}
export enum ChatResponseReferencePartStatusKind {
Complete = 1,
Partial = 2,
+15 -17
View File
@@ -21,6 +21,7 @@ import { IPaneComposite } from '../../common/panecomposite.js';
import { IComposite } from '../../common/composite.js';
import { CompositeDragAndDropData, CompositeDragAndDropObserver, IDraggedCompositeData, ICompositeDragAndDrop, Before2D, toggleDropEffect, ICompositeDragAndDropObserverCallbacks } from '../dnd.js';
import { Gesture, EventType as TouchEventType, GestureEvent } from '../../../base/browser/touch.js';
import { MutableDisposable } from '../../../base/common/lifecycle.js';
export interface ICompositeBarItem {
@@ -239,8 +240,8 @@ export class CompositeBar extends Widget implements ICompositeBar {
private dimension: Dimension | undefined;
private compositeSwitcherBar: ActionBar | undefined;
private compositeOverflowAction: CompositeOverflowActivityAction | undefined;
private compositeOverflowActionViewItem: CompositeOverflowActivityActionViewItem | undefined;
private compositeOverflowAction = this._register(new MutableDisposable<CompositeOverflowActivityAction>());
private compositeOverflowActionViewItem = this._register(new MutableDisposable<CompositeOverflowActivityActionViewItem>());
private readonly model: CompositeBarModel;
private readonly visibleComposites: string[];
@@ -287,7 +288,7 @@ export class CompositeBar extends Widget implements ICompositeBar {
this.compositeSwitcherBar = this._register(new ActionBar(actionBarDiv, {
actionViewItemProvider: (action, options) => {
if (action instanceof CompositeOverflowActivityAction) {
return this.compositeOverflowActionViewItem;
return this.compositeOverflowActionViewItem.value;
}
const item = this.model.findItem(action.id);
return item && this.instantiationService.createInstance(
@@ -578,14 +579,11 @@ export class CompositeBar extends Widget implements ICompositeBar {
}
// Remove the overflow action if there are no overflows
if (totalComposites === compositesToShow.length && this.compositeOverflowAction) {
if (totalComposites === compositesToShow.length && this.compositeOverflowAction.value) {
compositeSwitcherBar.pull(compositeSwitcherBar.length() - 1);
this.compositeOverflowAction.dispose();
this.compositeOverflowAction = undefined;
this.compositeOverflowActionViewItem?.dispose();
this.compositeOverflowActionViewItem = undefined;
this.compositeOverflowAction.value = undefined;
this.compositeOverflowActionViewItem.value = undefined;
}
// Pull out composites that overflow or got hidden
@@ -615,13 +613,13 @@ export class CompositeBar extends Widget implements ICompositeBar {
});
// Add overflow action as needed
if (totalComposites > compositesToShow.length && !this.compositeOverflowAction) {
this.compositeOverflowAction = this._register(this.instantiationService.createInstance(CompositeOverflowActivityAction, () => {
this.compositeOverflowActionViewItem?.showMenu();
}));
this.compositeOverflowActionViewItem = this._register(this.instantiationService.createInstance(
if (totalComposites > compositesToShow.length && !this.compositeOverflowAction.value) {
this.compositeOverflowAction.value = this.instantiationService.createInstance(CompositeOverflowActivityAction, () => {
this.compositeOverflowActionViewItem.value?.showMenu();
});
this.compositeOverflowActionViewItem.value = this.instantiationService.createInstance(
CompositeOverflowActivityActionViewItem,
this.compositeOverflowAction,
this.compositeOverflowAction.value,
() => this.getOverflowingComposites(),
() => this.model.activeItem ? this.model.activeItem.id : undefined,
compositeId => {
@@ -631,9 +629,9 @@ export class CompositeBar extends Widget implements ICompositeBar {
this.options.getOnCompositeClickAction,
this.options.colors,
this.options.activityHoverOptions
));
);
compositeSwitcherBar.push(this.compositeOverflowAction, { label: false, icon: true });
compositeSwitcherBar.push(this.compositeOverflowAction.value, { label: false, icon: true });
}
if (!donotTrigger) {
@@ -1876,3 +1876,29 @@ registerAction2(class ToggleChatViewTitleAction extends Action2 {
await configurationService.updateValue(ChatConfiguration.ChatViewTitleEnabled, !chatViewTitleEnabled);
}
});
registerAction2(class ToggleChatViewWelcomeAction extends Action2 {
constructor() {
super({
id: 'workbench.action.chat.toggleChatViewWelcome',
title: localize2('chat.toggleChatViewWelcome.label', "Show Welcome"),
category: CHAT_CATEGORY,
precondition: ChatContextKeys.enabled,
toggled: ContextKeyExpr.equals(`config.${ChatConfiguration.ChatViewWelcomeEnabled}`, true),
menu: {
id: MenuId.ChatWelcomeContext,
group: '1_modify',
order: 3,
when: ChatContextKeys.inChatEditor.negate()
}
});
}
async run(accessor: ServicesAccessor): Promise<void> {
const configurationService = accessor.get(IConfigurationService);
const chatViewWelcomeEnabled = configurationService.getValue<boolean>(ChatConfiguration.ChatViewWelcomeEnabled);
await configurationService.updateValue(ChatConfiguration.ChatViewWelcomeEnabled, !chatViewWelcomeEnabled);
}
});
@@ -37,6 +37,7 @@ import { ChatAgentLocation } from '../../common/constants.js';
import { PROMPT_LANGUAGE_ID } from '../../common/promptSyntax/promptTypes.js';
import { AgentSessionProviders, getAgentSessionProviderIcon, getAgentSessionProviderName } from '../agentSessions/agentSessions.js';
import { IChatWidgetService } from '../chat.js';
import { ctxHasEditorModification } from '../chatEditing/chatEditingEditorContextKeys.js';
import { CHAT_SETUP_ACTION_ID } from './chatActions.js';
import { PromptFileVariableKind, toPromptFileVariableEntry } from '../../common/chatVariableEntries.js';
import { NEW_CHAT_SESSION_ACTION_ID } from '../chatSessions/common.js';
@@ -73,6 +74,7 @@ export class ContinueChatInSessionAction extends Action2 {
ContextKeyExpr.equals(ResourceContextKey.Scheme.key, Schemas.untitled),
ContextKeyExpr.equals(ResourceContextKey.LangId.key, PROMPT_LANGUAGE_ID),
ContextKeyExpr.notEquals(chatEditingWidgetFileStateContextKey.key, ModifiedFileEntryState.Modified),
ctxHasEditorModification.negate(),
),
}
]
@@ -5,7 +5,7 @@
import './media/agentsessionsactions.css';
import { localize, localize2 } from '../../../../../nls.js';
import { IAgentSession } from './agentSessionsModel.js';
import { getAgentChangesSummary, IAgentSession } from './agentSessionsModel.js';
import { Action, IAction } from '../../../../../base/common/actions.js';
import { ActionViewItem, IActionViewItemOptions } from '../../../../../base/browser/ui/actionbar/actionViewItems.js';
import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js';
@@ -112,7 +112,7 @@ export class AgentSessionDiffActionViewItem extends ActionViewItem {
label.textContent = '';
const session = this.action.getSession();
const diff = session.statistics;
const diff = getAgentChangesSummary(session.changes);
if (!diff) {
return;
}
@@ -239,8 +239,12 @@ export class AgentSessionsControl extends Disposable {
this.sessionsList?.openFind();
}
refresh(): void {
this.agentSessionsService.model.resolve(undefined);
refresh(): Promise<void> {
return this.agentSessionsService.model.resolve(undefined);
}
update(): void {
this.sessionsList?.updateChildren();
}
setVisible(visible: boolean): void {
@@ -256,9 +260,7 @@ export class AgentSessionsControl extends Disposable {
}
focus(): void {
if (this.sessionsList?.getFocus().length) {
this.sessionsList.domFocus();
}
this.sessionsList?.domFocus();
}
clearFocus(): void {
@@ -16,7 +16,7 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com
import { ILogService } from '../../../../../platform/log/common/log.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';
import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js';
import { ChatSessionStatus, IChatSessionItem, IChatSessionsExtensionPoint, IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js';
import { ChatSessionStatus, IChatSessionFileChange, IChatSessionItem, IChatSessionsExtensionPoint, IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js';
import { AgentSessionProviders, getAgentSessionProviderIcon, getAgentSessionProviderName } from './agentSessions.js';
//#region Interfaces, Types
@@ -56,13 +56,32 @@ interface IAgentSessionData {
readonly finishedOrFailedTime?: number;
};
readonly statistics?: {
readonly changes?: readonly IChatSessionFileChange[] | {
readonly files: number;
readonly insertions: number;
readonly deletions: number;
};
}
export function getAgentChangesSummary(changes: IAgentSession['changes']) {
if (!changes) {
return;
}
if (!(changes instanceof Array)) {
return changes;
}
let insertions = 0;
let deletions = 0;
for (const change of changes) {
insertions += change.insertions;
deletions += change.deletions;
}
return { files: changes.length, insertions, deletions };
}
export interface IAgentSession extends IAgentSessionData {
isArchived(): boolean;
setArchived(archived: boolean): void;
@@ -264,6 +283,11 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode
});
}
const changes = session.changes;
const normalizedChanges = changes && !(changes instanceof Array)
? { files: changes.files, insertions: changes.insertions, deletions: changes.deletions }
: changes;
sessions.set(session.resource, this.toAgentSession({
providerType: provider.chatSessionType,
providerLabel,
@@ -280,7 +304,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode
inProgressTime,
finishedOrFailedTime
},
statistics: session.statistics,
changes: normalizedChanges,
}));
}
}
@@ -365,6 +389,7 @@ interface ISerializedAgentSession {
readonly files: number;
readonly insertions: number;
readonly deletions: number;
readonly details: readonly IChatSessionFileChange[];
};
}
@@ -413,7 +438,7 @@ class AgentSessionsCache {
endTime: session.timing.endTime,
},
statistics: session.statistics,
changes: session.changes,
}));
this.storageService.store(AgentSessionsCache.SESSIONS_STORAGE_KEY, JSON.stringify(serialized), StorageScope.WORKSPACE, StorageTarget.MACHINE);
@@ -446,7 +471,7 @@ class AgentSessionsCache {
endTime: session.timing.endTime,
},
statistics: session.statistics,
changes: session.statistics,
}));
} catch {
return []; // invalid data in storage, fallback to empty sessions list
@@ -157,10 +157,12 @@ export class AgentSessionRenderer implements ICompressibleTreeRenderer<IAgentSes
template.titleToolbar.context = session.element;
// Details Actions
const { statistics: diff } = session.element;
if (session.element.status !== ChatSessionStatus.InProgress && diff && (diff.files > 0 || diff.insertions > 0 || diff.deletions > 0)) {
const diffAction = template.elementDisposable.add(new AgentSessionShowDiffAction(session.element));
template.detailsToolbar.push([diffAction], { icon: false, label: true });
const { changes: diff } = session.element;
if (session.element.status !== ChatSessionStatus.InProgress && diff) {
if (diff instanceof Array ? diff.length > 0 : (diff.files > 0 || diff.insertions > 0 || diff.deletions > 0)) {
const diffAction = template.elementDisposable.add(new AgentSessionShowDiffAction(session.element));
template.detailsToolbar.push([diffAction], { icon: false, label: true });
}
}
// Description otherwise
@@ -188,23 +190,22 @@ export class AgentSessionRenderer implements ICompressibleTreeRenderer<IAgentSes
}
private renderDescription(session: ITreeNode<IAgentSession, FuzzyScore>, template: IAgentSessionItemTemplate): void {
// Support description as string
if (typeof session.element.description === 'string') {
template.description.textContent = session.element.description;
}
// or as markdown
else if (session.element.description) {
template.elementDisposable.add(this.markdownRendererService.render(session.element.description, {
sanitizerConfig: {
replaceWithPlaintext: true,
allowedTags: {
override: allowedChatMarkdownHtmlTags,
const description = session.element.description;
if (description) {
// Support description as string
if (typeof description === 'string') {
template.description.textContent = description;
} else {
template.elementDisposable.add(this.markdownRendererService.render(description, {
sanitizerConfig: {
replaceWithPlaintext: true,
allowedTags: {
override: allowedChatMarkdownHtmlTags,
},
allowedLinkSchemes: { augment: [this.productService.urlProtocol] }
},
allowedLinkSchemes: { augment: [this.productService.urlProtocol] }
},
}, template.description));
}, template.description));
}
}
// Fallback to state label
@@ -327,7 +328,7 @@ export interface IAgentSessionsFilter {
/**
* Optional limit on the number of sessions to show.
*/
readonly limitResults?: number;
readonly limitResults?: () => number | undefined;
/**
* A callback to notify the filter about the number of
@@ -358,9 +359,10 @@ export class AgentSessionsDataSource implements IAsyncDataSource<IAgentSessionsM
let filteredSessions = element.sessions.filter(session => !this.filter?.exclude?.(session));
// Apply limiter if configured (requires sorting)
if (this.filter?.limitResults !== undefined) {
const limitResultsCount = this.filter?.limitResults?.();
if (typeof limitResultsCount === 'number') {
filteredSessions.sort(this.sorter.compare.bind(this.sorter));
filteredSessions = filteredSessions.slice(0, this.filter.limitResults);
filteredSessions = filteredSessions.slice(0, limitResultsCount);
}
// Callback results count
@@ -151,10 +151,10 @@ export class LocalAgentsSessionsProvider extends Disposable implements IChatSess
startTime,
endTime
},
statistics: chat.stats ? {
changes: chat.stats ? {
insertions: chat.stats.added,
deletions: chat.stats.removed,
files: chat.stats.fileCount
files: chat.stats.fileCount,
} : undefined
};
}
@@ -6,6 +6,7 @@
.agent-sessions-viewer {
flex: 1 1 auto;
height: 100%;
min-height: 0;
.monaco-list-row .force-no-twistie {
@@ -364,6 +364,11 @@ configurationRegistry.registerConfiguration({
enum: ['inline', 'hover', 'input', 'none'],
default: 'inline',
},
[ChatConfiguration.ChatViewWelcomeEnabled]: {
type: 'boolean',
default: true,
description: nls.localize('chat.welcome.enabled', "Show welcome banner when chat is empty."),
},
[ChatConfiguration.ChatViewRecentSessionsEnabled]: { // TODO@bpasero move off preview
type: 'boolean',
default: true,
@@ -29,9 +29,9 @@ import { MultiDiffEditorItem } from '../../../multiDiffEditor/browser/multiDiffS
import { ChatContextKeys } from '../../common/chatContextKeys.js';
import { IEditSessionEntryDiff } from '../../common/chatEditingService.js';
import { IChatMultiDiffData, IChatMultiDiffInnerData } from '../../common/chatService.js';
import { getChatSessionType } from '../../common/chatUri.js';
import { IChatRendererContent } from '../../common/chatViewModel.js';
import { ChatTreeItem } from '../chat.js';
import { ChatEditorInput } from '../chatEditorInput.js';
import { IChatContentPart } from './chatContentParts.js';
const $ = dom.$;
@@ -57,12 +57,12 @@ export class ChatMultiDiffContentPart extends Disposable implements IChatContent
constructor(
private readonly content: IChatMultiDiffData,
_element: ChatTreeItem,
private readonly _element: ChatTreeItem,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IEditorService private readonly editorService: IEditorService,
@IThemeService private readonly themeService: IThemeService,
@IMenuService private readonly menuService: IMenuService,
@IContextKeyService private readonly contextKeyService: IContextKeyService
@IContextKeyService private readonly contextKeyService: IContextKeyService,
) {
super();
@@ -143,19 +143,17 @@ export class ChatMultiDiffContentPart extends Disposable implements IChatContent
}));
const setupActionBar = () => {
actionBar.clear();
const type = getChatSessionType(this._element.sessionResource);
let marshalledUri: unknown | undefined = undefined;
let contextKeyService: IContextKeyService = this.contextKeyService;
if (this.editorService.activeEditor instanceof ChatEditorInput) {
contextKeyService = this.contextKeyService.createOverlay([
[ChatContextKeys.agentSessionType.key, this.editorService.activeEditor.getSessionType()]
]);
marshalledUri = {
...this.editorService.activeEditor.resource,
$mid: MarshalledId.Uri
};
}
contextKeyService = this.contextKeyService.createOverlay([
[ChatContextKeys.agentSessionType.key, type]
]);
marshalledUri = {
...this._element.sessionResource,
$mid: MarshalledId.Uri
};
const actions = this.menuService.getMenuActions(
MenuId.ChatMultiDiffContext,
@@ -56,7 +56,15 @@ export interface IChatReferenceListItem extends IChatContentReference {
excluded?: boolean;
}
export type IChatCollapsibleListItem = IChatReferenceListItem | IChatWarningMessage;
export interface IChatListDividerItem {
kind: 'divider';
label: string;
menuId?: MenuId;
menuArg?: unknown;
scopedInstantiationService?: IInstantiationService;
}
export type IChatCollapsibleListItem = IChatReferenceListItem | IChatWarningMessage | IChatListDividerItem;
export class ChatCollapsibleListContentPart extends ChatCollapsibleContentPart {
@@ -205,7 +213,7 @@ export class CollapsibleListPool extends Disposable {
'ChatListRenderer',
container,
new CollapsibleListDelegate(),
[this.instantiationService.createInstance(CollapsibleListRenderer, resourceLabels, this.menuId)],
[this.instantiationService.createInstance(CollapsibleListRenderer, resourceLabels, this.menuId), this.instantiationService.createInstance(DividerRenderer)],
{
...this.listOptions,
alwaysConsumeMouseWheel: false,
@@ -214,6 +222,9 @@ export class CollapsibleListPool extends Disposable {
if (element.kind === 'warning') {
return element.content.value;
}
if (element.kind === 'divider') {
return element.label;
}
const reference = element.reference;
if (typeof reference === 'string') {
return reference;
@@ -278,6 +289,9 @@ class CollapsibleListDelegate implements IListVirtualDelegate<IChatCollapsibleLi
}
getTemplateId(element: IChatCollapsibleListItem): string {
if (element.kind === 'divider') {
return DividerRenderer.TEMPLATE_ID;
}
return CollapsibleListRenderer.TEMPLATE_ID;
}
}
@@ -348,6 +362,11 @@ class CollapsibleListRenderer implements IListRenderer<IChatCollapsibleListItem,
return;
}
if (data.kind === 'divider') {
// Dividers are handled by DividerRenderer
return;
}
const reference = data.reference;
const icon = this.getReferenceIcon(data);
templateData.label.element.style.display = 'flex';
@@ -386,7 +405,7 @@ class CollapsibleListRenderer implements IListRenderer<IChatCollapsibleListItem,
const settingId = uri.path.substring(1);
templateData.label.setResource({ resource: uri, name: settingId }, { icon: Codicon.settingsGear, title: localize('setting.hover', "Open setting '{0}'", settingId), strikethrough: data.excluded, extraClasses });
} else if (matchesSomeScheme(uri, Schemas.mailto, Schemas.http, Schemas.https)) {
templateData.label.setResource({ resource: uri, name: uri.toString() }, { icon: icon ?? Codicon.globe, title: data.options?.status?.description ?? data.title ?? uri.toString(), strikethrough: data.excluded, extraClasses });
templateData.label.setResource({ resource: uri, name: uri.toString(true) }, { icon: icon ?? Codicon.globe, title: data.options?.status?.description ?? data.title ?? uri.toString(true), strikethrough: data.excluded, extraClasses });
} else {
templateData.label.setFile(uri, {
fileKind: FileKind.FILE,
@@ -414,23 +433,17 @@ class CollapsibleListRenderer implements IListRenderer<IChatCollapsibleListItem,
if (data.state !== undefined) {
if (templateData.actionBarContainer) {
if (data.state === ModifiedFileEntryState.Modified && !templateData.actionBarContainer.classList.contains('modified')) {
const diffMeta = data?.options?.diffMeta;
if (diffMeta) {
if (!templateData.fileDiffsContainer || !templateData.addedSpan || !templateData.removedSpan) {
return;
}
templateData.addedSpan.textContent = `+${diffMeta.added}`;
templateData.removedSpan.textContent = `-${diffMeta.removed}`;
templateData.fileDiffsContainer.setAttribute('aria-label', localize('chatEditingSession.fileCounts', '{0} lines added, {1} lines removed', diffMeta.added, diffMeta.removed));
const diffMeta = data?.options?.diffMeta;
if (diffMeta) {
if (!templateData.fileDiffsContainer || !templateData.addedSpan || !templateData.removedSpan) {
return;
}
// eslint-disable-next-line no-restricted-syntax
templateData.label.element.querySelector('.monaco-icon-name-container')?.classList.add('modified');
} else if (data.state !== ModifiedFileEntryState.Modified) {
templateData.actionBarContainer.classList.remove('modified');
// eslint-disable-next-line no-restricted-syntax
templateData.label.element.querySelector('.monaco-icon-name-container')?.classList.remove('modified');
templateData.addedSpan.textContent = `+${diffMeta.added}`;
templateData.removedSpan.textContent = `-${diffMeta.removed}`;
templateData.fileDiffsContainer.setAttribute('aria-label', localize('chatEditingSession.fileCounts', '{0} lines added, {1} lines removed', diffMeta.added, diffMeta.removed));
}
// eslint-disable-next-line no-restricted-syntax
templateData.label.element.querySelector('.monaco-icon-name-container')?.classList.add('modified');
}
if (templateData.toolbar) {
templateData.toolbar.context = arg;
@@ -448,6 +461,54 @@ class CollapsibleListRenderer implements IListRenderer<IChatCollapsibleListItem,
}
}
interface IDividerTemplate {
readonly container: HTMLElement;
readonly label: HTMLElement;
readonly line: HTMLElement;
readonly toolbarContainer: HTMLElement;
readonly templateDisposables: DisposableStore;
readonly elementDisposables: DisposableStore;
toolbar: MenuWorkbenchToolBar | undefined;
}
class DividerRenderer implements IListRenderer<IChatListDividerItem, IDividerTemplate> {
static TEMPLATE_ID = 'chatListDividerRenderer';
readonly templateId: string = DividerRenderer.TEMPLATE_ID;
constructor(
@IInstantiationService private readonly instantiationService: IInstantiationService,
) { }
renderTemplate(container: HTMLElement): IDividerTemplate {
const templateDisposables = new DisposableStore();
const elementDisposables = templateDisposables.add(new DisposableStore());
container.classList.add('chat-list-divider');
const label = dom.append(container, dom.$('span.chat-list-divider-label'));
const line = dom.append(container, dom.$('div.chat-list-divider-line'));
const toolbarContainer = dom.append(container, dom.$('.chat-list-divider-toolbar'));
return { container, label, line, toolbarContainer, templateDisposables, elementDisposables, toolbar: undefined };
}
renderElement(data: IChatListDividerItem, index: number, templateData: IDividerTemplate): void {
templateData.label.textContent = data.label;
// Clear element-specific disposables from previous render
templateData.elementDisposables.clear();
templateData.toolbar = undefined;
dom.clearNode(templateData.toolbarContainer);
if (data.menuId) {
const instantiationService = data.scopedInstantiationService || this.instantiationService;
templateData.toolbar = templateData.elementDisposables.add(instantiationService.createInstance(MenuWorkbenchToolBar, templateData.toolbarContainer, data.menuId, { menuOptions: { arg: data.menuArg } }));
}
}
disposeTemplate(templateData: IDividerTemplate): void {
templateData.templateDisposables.dispose();
}
}
function getResourceLabelForGithubUri(uri: URI): IResourceLabelProps {
const repoPath = uri.path.split('/').slice(1, 3).join('/');
const filePath = uri.path.split('/').slice(5);
@@ -492,7 +553,7 @@ function getLineRangeFromGithubUri(uri: URI): IRange | undefined {
}
function getResourceForElement(element: IChatCollapsibleListItem): URI | null {
if (element.kind === 'warning') {
if (element.kind === 'warning' || element.kind === 'divider') {
return null;
}
const { reference } = element;
@@ -151,6 +151,9 @@
width: 100%;
background: inherit;
}
.chat-terminal-output-container.chat-terminal-output-container-no-output .chat-terminal-output-body {
padding-bottom: 5px;
}
.chat-terminal-output-container:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 2px;
@@ -911,11 +911,13 @@ class ChatTerminalToolOutputSection extends Disposable {
private _showEmptyMessage(message: string): void {
this._emptyElement.textContent = message;
this._terminalContainer.classList.add('chat-terminal-output-terminal-no-output');
this.domNode.classList.add('chat-terminal-output-container-no-output');
}
private _hideEmptyMessage(): void {
this._emptyElement.textContent = '';
this._terminalContainer.classList.remove('chat-terminal-output-terminal-no-output');
this.domNode.classList.remove('chat-terminal-output-container-no-output');
}
private _disposeLiveMirror(): void {
@@ -6,7 +6,7 @@
import { CancellationToken } from '../../../../../base/common/cancellation.js';
import { Codicon } from '../../../../../base/common/codicons.js';
import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js';
import { basename } from '../../../../../base/common/resources.js';
import { basename, isEqual } from '../../../../../base/common/resources.js';
import { URI, UriComponents } from '../../../../../base/common/uri.js';
import { isCodeEditor } from '../../../../../editor/browser/editorBrowser.js';
import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js';
@@ -18,7 +18,7 @@ import { ILanguageFeaturesService } from '../../../../../editor/common/services/
import { ITextModelService } from '../../../../../editor/common/services/resolverService.js';
import { localize, localize2 } from '../../../../../nls.js';
import { Action2, IAction2Options, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js';
import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js';
import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js';
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js';
import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
@@ -26,6 +26,7 @@ import { EditorActivation } from '../../../../../platform/editor/common/editor.j
import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js';
import { IEditorPane } from '../../../../common/editor.js';
import { IEditorService } from '../../../../services/editor/common/editorService.js';
import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js';
import { isChatViewTitleActionContext } from '../../common/chatActions.js';
import { ChatContextKeys } from '../../common/chatContextKeys.js';
import { applyingChatEditsFailedContextKey, CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, chatEditingResourceContextKey, chatEditingWidgetFileStateContextKey, decidedChatEditingResourceContextKey, hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingService, IChatEditingSession, ModifiedFileEntryState } from '../../common/chatEditingService.js';
@@ -304,6 +305,58 @@ export class ChatEditingShowChangesAction extends EditingSessionAction {
}
registerAction2(ChatEditingShowChangesAction);
export class ViewAllSessionChangesAction extends Action2 {
static readonly ID = 'chatEditing.viewAllSessionChanges';
constructor() {
super({
id: ViewAllSessionChangesAction.ID,
title: localize2('chatEditing.viewAllSessionChanges', 'View All Changes'),
icon: Codicon.diffMultiple,
category: CHAT_CATEGORY,
precondition: ChatContextKeys.hasAgentSessionChanges,
menu: [
{
id: MenuId.ChatEditingSessionChangesToolbar,
group: 'navigation',
order: 10,
when: ChatContextKeys.hasAgentSessionChanges
}
],
});
}
override async run(accessor: ServicesAccessor, ...args: unknown[]): Promise<void> {
const chatWidgetService = accessor.get(IChatWidgetService);
const agentSessionsService = accessor.get(IAgentSessionsService);
const commandService = accessor.get(ICommandService);
const chatWidget = chatWidgetService.lastFocusedWidget ?? chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Chat).find(w => w.supportsChangingModes);
if (!chatWidget?.viewModel) {
return;
}
const sessionResource = chatWidget.viewModel.model.sessionResource;
const session = agentSessionsService.model.sessions.find(s => isEqual(s.resource, sessionResource));
const changes = session?.changes;
if (!(changes instanceof Array)) {
return;
}
const resources = changes
.filter(d => d.originalUri)
.map(d => ({ originalUri: d.originalUri!, modifiedUri: d.modifiedUri }));
if (resources.length > 0) {
await commandService.executeCommand('_workbench.openMultiDiffEditor', {
title: localize('chatEditing.allChanges.title', 'All Session Changes'),
resources,
});
}
}
}
registerAction2(ViewAllSessionChangesAction);
async function restoreSnapshotWithConfirmation(accessor: ServicesAccessor, item: ChatTreeItem): Promise<void> {
const configurationService = accessor.get(IConfigurationService);
const dialogService = accessor.get(IDialogService);
@@ -26,7 +26,7 @@ import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposab
import { ResourceSet } from '../../../../base/common/map.js';
import { MarshalledId } from '../../../../base/common/marshallingIds.js';
import { Schemas } from '../../../../base/common/network.js';
import { autorun, derived, derivedOpts, IObservable, ISettableObservable, observableValue } from '../../../../base/common/observable.js';
import { autorun, derived, derivedOpts, IObservable, ISettableObservable, observableFromEvent, observableValue } from '../../../../base/common/observable.js';
import { isMacintosh } from '../../../../base/common/platform.js';
import { isEqual } from '../../../../base/common/resources.js';
import { ScrollbarVisibility } from '../../../../base/common/scrollable.js';
@@ -63,6 +63,7 @@ import { IKeybindingService } from '../../../../platform/keybinding/common/keybi
import { ILabelService } from '../../../../platform/label/common/label.js';
import { WorkbenchList } from '../../../../platform/list/browser/listService.js';
import { ILogService } from '../../../../platform/log/common/log.js';
import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
import { IThemeService } from '../../../../platform/theme/common/themeService.js';
import { ISharedWebContentExtractorService } from '../../../../platform/webContentExtractor/common/webContentExtractor.js';
@@ -81,7 +82,8 @@ import { IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from
import { IChatModelInputState, IChatRequestModeInfo, IInputModel } from '../common/chatModel.js';
import { ChatMode, IChatMode, IChatModeService } from '../common/chatModes.js';
import { IChatFollowup, IChatService } from '../common/chatService.js';
import { IChatSessionProviderOptionItem, IChatSessionsService } from '../common/chatSessionsService.js';
import { IChatSessionFileChange, IChatSessionProviderOptionItem, IChatSessionsService, localChatSessionType } from '../common/chatSessionsService.js';
import { getChatSessionType } from '../common/chatUri.js';
import { ChatRequestVariableSet, IChatRequestVariableEntry, isElementVariableEntry, isImageVariableEntry, isNotebookOutputVariableEntry, isPasteVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry, isSCMHistoryItemChangeRangeVariableEntry, isSCMHistoryItemChangeVariableEntry, isSCMHistoryItemVariableEntry, isStringVariableEntry } from '../common/chatVariableEntries.js';
import { IChatResponseViewModel } from '../common/chatViewModel.js';
import { ChatHistoryNavigator } from '../common/chatWidgetHistoryService.js';
@@ -90,6 +92,7 @@ import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, IL
import { ILanguageModelToolsService } from '../common/languageModelToolsService.js';
import { ActionLocation, ChatContinueInSessionActionItem, ContinueChatInSessionAction } from './actions/chatContinueInAction.js';
import { ChatOpenModelPickerActionId, ChatSessionPrimaryPickerAction, ChatSubmitAction, IChatExecuteActionContext, OpenModePickerAction } from './actions/chatExecuteActions.js';
import { IAgentSessionsService } from './agentSessions/agentSessionsService.js';
import { ImplicitContextAttachmentWidget } from './attachments/implicitContextAttachment.js';
import { IChatWidget } from './chat.js';
import { ChatAttachmentModel } from './chatAttachmentModel.js';
@@ -97,11 +100,11 @@ import { DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmen
import { IDisposableReference } from './chatContentParts/chatCollections.js';
import { CollapsibleListPool, IChatCollapsibleListItem } from './chatContentParts/chatReferencesContentPart.js';
import { ChatTodoListWidget } from './chatContentParts/chatTodoListWidget.js';
import { ChatInputPartWidgetController } from './chatInputPartWidgets.js';
import { IChatContextService } from './chatContextService.js';
import { ChatDragAndDrop } from './chatDragAndDrop.js';
import { ChatEditingShowChangesAction, ViewPreviousEditsAction } from './chatEditing/chatEditingActions.js';
import { ChatEditingShowChangesAction, ViewAllSessionChangesAction, ViewPreviousEditsAction } from './chatEditing/chatEditingActions.js';
import { ChatFollowups } from './chatFollowups.js';
import { ChatInputPartWidgetController } from './chatInputPartWidgets.js';
import { ChatSelectedTools } from './chatSelectedTools.js';
import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from './chatSessions/chatSessionPickerActionItem.js';
import { ChatImplicitContext } from './contrib/chatImplicitContext.js';
@@ -145,7 +148,7 @@ export interface IWorkingSetEntry {
export class ChatInputPart extends Disposable implements IHistoryNavigationWidget {
private static _counter = 0;
private _workingSetCollapsed = true;
private _workingSetCollapsed = observableValue('chatInputPart.workingSetCollapsed', true);
private readonly _chatInputTodoListWidget = this._register(new MutableDisposable<ChatTodoListWidget>());
private readonly _chatEditingTodosDisposables = this._register(new DisposableStore());
private _lastEditingSessionResource: URI | undefined;
@@ -428,6 +431,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
@IChatService private readonly chatService: IChatService,
@IChatSessionsService private readonly chatSessionsService: IChatSessionsService,
@IChatContextService private readonly chatContextService: IChatContextService,
@IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService,
) {
super();
@@ -444,7 +448,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
// React to chat session option changes for the active session
this._register(this.chatSessionsService.onDidChangeSessionOptions(e => {
const sessionResource = this._widget?.viewModel?.model.sessionResource;
if (sessionResource && isEqual(sessionResource, e.resource)) {
if (sessionResource && isEqual(sessionResource, e)) {
// Options changed for our current session - refresh pickers
this.refreshChatSessionPickers();
}
@@ -710,7 +714,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
this.getOrCreateOptionEmitter(optionGroup.id).fire(option);
this.chatSessionsService.notifySessionOptionsChange(
ctx.chatSessionResource,
[{ optionId: optionGroup.id, value: option.id }]
[{ optionId: optionGroup.id, value: option }]
).catch(err => this.logService.error(`Failed to notify extension of ${optionGroup.id} change:`, err));
},
getAllOptions: () => {
@@ -1270,9 +1274,16 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
if (currentOption) {
const optionGroup = optionGroups.find(g => g.id === optionGroupId);
if (optionGroup) {
const item = optionGroup.items.find(m => m.id === currentOption);
const currentOptionId = typeof currentOption === 'string' ? currentOption : currentOption.id;
const item = optionGroup.items.find(m => m.id === currentOptionId);
if (item) {
this.getOrCreateOptionEmitter(optionGroupId).fire(item);
// If currentOption is an object (not a string ID), it represents a complete option item and should be used directly.
// Otherwise, if it's a string ID, look up the corresponding item and use that.
if (typeof currentOption === 'string') {
this.getOrCreateOptionEmitter(optionGroupId).fire(item);
} else {
this.getOrCreateOptionEmitter(optionGroupId).fire(currentOption);
}
}
}
}
@@ -1324,11 +1335,35 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
}
/**
* Updates the widget controller based on session type.
*/
private tryUpdateWidgetController(): void {
const sessionResource = this._widget?.viewModel?.model.sessionResource;
if (!sessionResource) {
return;
}
const sessionType = getChatSessionType(sessionResource);
const isLocalSession = sessionType === localChatSessionType;
if (!isLocalSession) {
this._widgetController.clear();
return;
}
if (!this._widgetController.value) {
this._widgetController.value = this.instantiationService.createInstance(ChatInputPartWidgetController, this.chatInputWidgetsContainer);
this._register(this._widgetController.value.onDidChangeHeight(() => this._onDidChangeHeight.fire()));
}
}
render(container: HTMLElement, initialValue: string, widget: IChatWidget) {
this._widget = widget;
this._register(widget.onDidChangeViewModel(() => {
this.refreshChatSessionPickers();
this.tryUpdateWidgetController();
}));
let elements;
@@ -1404,8 +1439,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
this._implicitContext = undefined;
}
this._widgetController.value = this.instantiationService.createInstance(ChatInputPartWidgetController, this.chatInputWidgetsContainer);
this._register(this._widgetController.value.onDidChangeHeight(() => this._onDidChangeHeight.fire()));
this.tryUpdateWidgetController();
this.renderAttachedContext();
this._register(this._attachmentModel.onDidChange((e) => {
@@ -1945,7 +1979,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
if (chatEditingSession) {
if (!isEqual(chatEditingSession.chatSessionResource, this._lastEditingSessionResource)) {
this._workingSetCollapsed = true;
this._workingSetCollapsed.set(true, undefined);
}
this._lastEditingSessionResource = chatEditingSession.chatSessionResource;
}
@@ -1954,7 +1988,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
return chatEditingSession?.entries.read(r).filter(entry => entry.state.read(r) === ModifiedFileEntryState.Modified) || [];
});
const listEntries = derived((reader): IChatCollapsibleListItem[] => {
const editSessionEntries = derived((reader): IChatCollapsibleListItem[] => {
const seenEntries = new ResourceSet();
const entries: IChatCollapsibleListItem[] = [];
for (const entry of modifiedEntries.read(reader)) {
@@ -1991,15 +2025,43 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
return entries;
});
const shouldRender = listEntries.map(r => r.length > 0);
const sessionFileChanges = observableFromEvent(
this,
this.agentSessionsService.model.onDidChangeSessions,
() => {
const sessionResource = this._widget?.viewModel?.model?.sessionResource;
if (!sessionResource) {
return Iterable.empty();
}
const model = this.agentSessionsService.model.sessions.find(s => isEqual(s.resource, sessionResource));
return model?.changes instanceof Array ? model.changes : Iterable.empty();
},
);
const sessionFiles = derived(reader =>
sessionFileChanges.read(reader).map((entry): IChatCollapsibleListItem => ({
reference: entry.modifiedUri,
state: ModifiedFileEntryState.Accepted,
kind: 'reference',
options: {
status: undefined,
diffMeta: { added: entry.insertions, removed: entry.deletions },
originalUri: entry.originalUri,
}
}))
);
const shouldRender = derived(reader => editSessionEntries.read(reader).length > 0 || sessionFiles.read(reader).length > 0);
this._renderingChatEdits.value = autorun(reader => {
if (this.options.renderWorkingSet && shouldRender.read(reader)) {
this.renderChatEditingSessionWithEntries(
reader.store,
chatEditingSession!,
chatEditingSession,
modifiedEntries,
listEntries,
sessionFileChanges,
editSessionEntries,
sessionFiles,
);
} else {
dom.clearNode(this.chatEditingSessionWidgetContainer);
@@ -2008,12 +2070,13 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
}
});
}
private renderChatEditingSessionWithEntries(
store: DisposableStore,
chatEditingSession: IChatEditingSession,
chatEditingSession: IChatEditingSession | null,
modifiedEntries: IObservable<IModifiedFileEntry[]>,
listEntries: IObservable<IChatCollapsibleListItem[]>,
sessionFileChanges: IObservable<readonly IChatSessionFileChange[] | undefined>,
editSessionEntries: IObservable<IChatCollapsibleListItem[]>,
sessionEntries: IObservable<IChatCollapsibleListItem[]>,
) {
// Summary of number of files changed
// eslint-disable-next-line no-restricted-syntax
@@ -2031,26 +2094,17 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
// eslint-disable-next-line no-restricted-syntax
const actionsContainer = overviewRegion.querySelector('.chat-editing-session-actions') as HTMLElement ?? dom.append(overviewRegion, $('.chat-editing-session-actions'));
this._chatEditsActionsDisposables.add(this.instantiationService.createInstance(MenuWorkbenchButtonBar, actionsContainer, MenuId.ChatEditingWidgetToolbar, {
telemetrySource: this.options.menus.telemetrySource,
menuOptions: {
arg: {
$mid: MarshalledId.ChatViewContext,
sessionResource: chatEditingSession.chatSessionResource,
} satisfies IChatViewTitleActionContext,
},
buttonConfigProvider: (action) => {
if (action.id === ChatEditingShowChangesAction.ID || action.id === ViewPreviousEditsAction.Id) {
return { showIcon: true, showLabel: false, isSecondary: true };
}
return undefined;
}
}));
const sessionResource = chatEditingSession?.chatSessionResource || this._widget?.viewModel?.model.sessionResource;
if (!chatEditingSession) {
return;
const scopedContextKeyService = this._chatEditsActionsDisposables.add(this.contextKeyService.createScoped(actionsContainer));
if (sessionResource) {
scopedContextKeyService.createKey(ChatContextKeys.agentSessionType.key, getChatSessionType(sessionResource));
}
this._chatEditsActionsDisposables.add(bindContextKey(ChatContextKeys.hasAgentSessionChanges, scopedContextKeyService, r => !!sessionEntries.read(r)?.length));
const scopedInstantiationService = this._chatEditsActionsDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, scopedContextKeyService])));
// Working set
// eslint-disable-next-line no-restricted-syntax
const workingSetContainer = innerContainer.querySelector('.chat-editing-session-list') as HTMLElement ?? dom.append(innerContainer, $('.chat-editing-session-list'));
@@ -2061,9 +2115,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
ariaLabel: localize('chatEditingSession.toggleWorkingSet', 'Toggle changed files.'),
}));
store.add(autorun(reader => {
const topLevelStats = derived(reader => {
let added = 0;
let removed = 0;
const entries = modifiedEntries.read(reader);
@@ -2073,14 +2125,56 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
removed += entry.linesRemoved.read(reader);
}
}
const baseLabel = entries.length === 1 ? localize('chatEditingSession.oneFile.1', '1 file changed') : localize('chatEditingSession.manyFiles.1', '{0} files changed', entries.length);
let baseLabel = entries.length === 1 ? localize('chatEditingSession.oneFile.1', '1 file changed') : localize('chatEditingSession.manyFiles.1', '{0} files changed', entries.length);
let shouldShowEditingSession = added > 0 || removed > 0;
let topLevelIsSessionMenu = false;
if (added === 0 && removed === 0) {
const sessionValue = sessionFileChanges.read(reader) || [];
for (const entry of sessionValue) {
added += entry.insertions;
removed += entry.deletions;
}
shouldShowEditingSession = sessionValue.length > 0;
baseLabel = sessionValue.length === 1 ? localize('chatEditingSession.oneFile.2', '1 file ready to merge') : localize('chatEditingSession.manyFiles.2', '{0} files ready to merge', sessionValue.length);
topLevelIsSessionMenu = true;
}
button.label = baseLabel;
return { added, removed, shouldShowEditingSession, baseLabel, topLevelIsSessionMenu };
});
const topLevelIsSessionMenu = topLevelStats.map(t => t.topLevelIsSessionMenu);
store.add(autorun(reader => {
const isSessionMenu = topLevelIsSessionMenu.read(reader);
reader.store.add(scopedInstantiationService.createInstance(MenuWorkbenchButtonBar, actionsContainer, isSessionMenu ? MenuId.ChatEditingSessionChangesToolbar : MenuId.ChatEditingWidgetToolbar, {
telemetrySource: this.options.menus.telemetrySource,
menuOptions: {
arg: sessionResource && (isSessionMenu ? sessionResource : {
$mid: MarshalledId.ChatViewContext,
sessionResource,
} satisfies IChatViewTitleActionContext),
},
buttonConfigProvider: (action) => {
if (action.id === ChatEditingShowChangesAction.ID || action.id === ViewPreviousEditsAction.Id || action.id === ViewAllSessionChangesAction.ID) {
return { showIcon: true, showLabel: false, isSecondary: true };
}
return undefined;
}
}));
}));
store.add(autorun(reader => {
const { added, removed, shouldShowEditingSession, baseLabel } = topLevelStats.read(reader);
button.label = baseLabel;
this._workingSetLinesAddedSpan.value.textContent = `+${added}`;
this._workingSetLinesRemovedSpan.value.textContent = `-${removed}`;
button.element.setAttribute('aria-label', localize('chatEditingSession.ariaLabelWithCounts', '{0}, {1} lines added, {2} lines removed', baseLabel, added, removed));
const shouldShowEditingSession = added > 0 || removed > 0;
dom.setVisibility(shouldShowEditingSession, this.chatEditingSessionWidgetContainer);
if (!shouldShowEditingSession) {
this._onDidChangeHeight.fire();
@@ -2092,18 +2186,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
countsContainer.appendChild(this._workingSetLinesAddedSpan.value);
countsContainer.appendChild(this._workingSetLinesRemovedSpan.value);
const applyCollapseState = () => {
button.icon = this._workingSetCollapsed ? Codicon.chevronRight : Codicon.chevronDown;
workingSetContainer.classList.toggle('collapsed', this._workingSetCollapsed);
this._onDidChangeHeight.fire();
};
const toggleWorkingSet = () => {
this._workingSetCollapsed = !this._workingSetCollapsed;
applyCollapseState();
this._workingSetCollapsed.set(!this._workingSetCollapsed.get(), undefined);
};
this._chatEditsActionsDisposables.add(button.onDidClick(() => { toggleWorkingSet(); }));
this._chatEditsActionsDisposables.add(button.onDidClick(toggleWorkingSet));
this._chatEditsActionsDisposables.add(addDisposableListener(overviewRegion, 'click', e => {
if (e.defaultPrevented) {
return;
@@ -2115,7 +2202,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
toggleWorkingSet();
}));
applyCollapseState();
this._chatEditsActionsDisposables.add(autorun(reader => {
const collapsed = this._workingSetCollapsed.read(reader);
button.icon = collapsed ? Codicon.chevronRight : Codicon.chevronDown;
workingSetContainer.classList.toggle('collapsed', collapsed);
this._onDidChangeHeight.fire();
}));
if (!this._chatEditList) {
this._chatEditList = this._chatEditsListPool.get();
@@ -2127,8 +2219,19 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
this._chatEditsDisposables.add(list.onDidOpen(async (e) => {
if (e.element?.kind === 'reference' && URI.isUri(e.element.reference)) {
const modifiedFileUri = e.element.reference;
const originalUri = e.element.options?.originalUri;
const entry = chatEditingSession.getEntry(modifiedFileUri);
// If there's a originalUri, open as diff editor
if (originalUri) {
await this.editorService.openEditor({
original: { resource: originalUri },
modified: { resource: modifiedFileUri },
options: e.editorOptions
}, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP);
return;
}
const entry = chatEditingSession?.getEntry(modifiedFileUri);
const pane = await this.editorService.openEditor({
resource: modifiedFileUri,
@@ -2150,14 +2253,32 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
}
store.add(autorun(reader => {
const entries = listEntries.read(reader);
const editEntries = editSessionEntries.read(reader);
const sessionFileEntries = sessionEntries.read(reader) ?? [];
// Combine entries with an optional divider
const allEntries: IChatCollapsibleListItem[] = [...editEntries];
if (sessionFileEntries.length > 0) {
if (editEntries.length > 0) {
// Add divider between edit session entries and session file entries
allEntries.push({
kind: 'divider',
label: localize('chatEditingSession.allChanges', 'Worktree Changes'),
menuId: MenuId.ChatEditingSessionChangesToolbar,
menuArg: sessionResource,
scopedInstantiationService,
});
}
allEntries.push(...sessionFileEntries);
}
const maxItemsShown = 6;
const itemsShown = Math.min(entries.length, maxItemsShown);
const itemsShown = Math.min(allEntries.length, maxItemsShown);
const height = itemsShown * 22;
const list = this._chatEditList!.object;
list.layout(height);
list.getHTMLElement().style.height = `${height}px`;
list.splice(0, list.length, entries);
list.splice(0, list.length, allEntries);
this._onDidChangeHeight.fire();
}));
}
@@ -136,6 +136,7 @@ export class ChatInputPartWidgetController extends Disposable {
override dispose(): void {
for (const rendered of this.renderedWidgets.values()) {
rendered.widget.domNode.remove();
rendered.disposables.dispose();
}
this.renderedWidgets.clear();
@@ -265,7 +265,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ
private readonly _onDidChangeContentProviderSchemes = this._register(new Emitter<{ readonly added: string[]; readonly removed: string[] }>());
public get onDidChangeContentProviderSchemes() { return this._onDidChangeContentProviderSchemes.event; }
private readonly _onDidChangeSessionOptions = this._register(new Emitter<{ readonly resource: URI; readonly updates: ReadonlyArray<{ optionId: string; value: string }> }>());
private readonly _onDidChangeSessionOptions = this._register(new Emitter<URI>());
public get onDidChangeSessionOptions() { return this._onDidChangeSessionOptions.event; }
private readonly inProgressMap: Map<string, number> = new Map();
@@ -953,33 +953,35 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ
for (let i = responseParts.length - 1; i >= 0; i--) {
const part = responseParts[i];
if (!description && part.kind === 'confirmation' && typeof part.message === 'string') {
description = part.message;
if (description) {
break;
}
if (!description && part.kind === 'toolInvocation') {
if (part.kind === 'confirmation' && typeof part.message === 'string') {
description = part.message;
} else if (part.kind === 'toolInvocation') {
const toolInvocation = part as IChatToolInvocation;
const state = toolInvocation.state.get();
if (state.type !== IChatToolInvocation.StateKind.Completed) {
const pastTenseMessage = toolInvocation.pastTenseMessage;
const invocationMessage = toolInvocation.invocationMessage;
description = pastTenseMessage || invocationMessage;
description = toolInvocation.pastTenseMessage || toolInvocation.invocationMessage;
if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation) {
const message = toolInvocation.confirmationMessages?.title && (typeof toolInvocation.confirmationMessages.title === 'string'
? toolInvocation.confirmationMessages.title
: toolInvocation.confirmationMessages.title.value);
description = message ?? localize('chat.sessions.description.waitingForConfirmation', "Waiting for confirmation: {0}", typeof description === 'string' ? description : description.value);
const confirmationTitle = toolInvocation.confirmationMessages?.title;
const titleMessage = confirmationTitle && (typeof confirmationTitle === 'string'
? confirmationTitle
: confirmationTitle.value);
const descriptionValue = typeof description === 'string' ? description : description.value;
description = titleMessage ?? localize('chat.sessions.description.waitingForConfirmation', "Waiting for confirmation: {0}", descriptionValue);
}
}
}
if (!description && part.kind === 'toolInvocationSerialized') {
} else if (part.kind === 'toolInvocationSerialized') {
description = part.invocationMessage;
}
if (!description && part.kind === 'progressMessage') {
} else if (part.kind === 'progressMessage') {
description = part.content;
}
}
return renderAsPlaintext(description, { useLinkFormatter: true });
}
@@ -1078,7 +1080,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ
/**
* Notify extension about option changes for a session
*/
public async notifySessionOptionsChange(sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string }>): Promise<void> {
public async notifySessionOptionsChange(sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>): Promise<void> {
if (!updates.length) {
return;
}
@@ -1088,7 +1090,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ
for (const u of updates) {
this.setSessionOption(sessionResource, u.optionId, u.value);
}
this._onDidChangeSessionOptions.fire({ resource: sessionResource, updates });
this._onDidChangeSessionOptions.fire(sessionResource);
}
/**
@@ -279,10 +279,23 @@ export class SessionsRenderer extends Disposable implements ITreeRenderer<IChatS
}
DOM.clearNode(templateData.statisticsLabel);
let insertions = 0;
let deletions = 0;
if (session.changes instanceof Array) {
for (const change of session.changes) {
insertions += change.insertions;
deletions += change.deletions;
}
} else if (session.changes) {
insertions = session.changes.insertions;
deletions = session.changes.deletions;
}
const insertionNode = append(templateData.statisticsLabel, $('span.insertions'));
insertionNode.textContent = session.statistics ? `+${session.statistics.insertions}` : '';
insertionNode.textContent = session.changes ? `+${insertions}` : '';
const deletionNode = append(templateData.statisticsLabel, $('span.deletions'));
deletionNode.textContent = session.statistics ? `-${session.statistics.deletions}` : '';
deletionNode.textContent = session.changes ? `-${deletions}` : '';
} else {
templateData.container.classList.toggle('multiline', false);
}
@@ -50,6 +50,10 @@ import { ChatViewTitleControl } from './chatViewTitleControl.js';
import { ChatViewWelcomeController, IViewWelcomeDelegate } from './viewsWelcome/chatViewWelcomeController.js';
import { IWorkbenchLayoutService, Position } from '../../../services/layout/browser/layoutService.js';
import { AgentSessionsViewerOrientation, AgentSessionsViewerPosition } from './agentSessions/agentSessions.js';
import { Link } from '../../../../platform/opener/browser/link.js';
import { IProgressService } from '../../../../platform/progress/common/progress.js';
import { ChatViewId } from './chat.js';
import { disposableTimeout } from '../../../../base/common/async.js';
interface IChatViewPaneState extends Partial<IChatModelInputState> {
sessionId?: string;
@@ -76,9 +80,12 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
private chatViewLocationContext: IContextKey<ViewContainerLocation>;
private sessionsContainer: HTMLElement | undefined;
private sessionsTitleContainer: HTMLElement | undefined;
private sessionsControlContainer: HTMLElement | undefined;
private sessionsControl: AgentSessionsControl | undefined;
private sessionsCount: number = 0;
private sessionsLinkContainer: HTMLElement | undefined;
private sessionsCount = 0;
private sessionsViewerLimited = true;
private sessionsViewerOrientation = AgentSessionsViewerOrientation.Stacked;
private sessionsViewerOrientationContext: IContextKey<AgentSessionsViewerOrientation>;
private sessionsViewerPosition = AgentSessionsViewerPosition.Right;
@@ -112,6 +119,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
@IChatSessionsService private readonly chatSessionsService: IChatSessionsService,
@ITelemetryService private readonly telemetryService: ITelemetryService,
@ILifecycleService lifecycleService: ILifecycleService,
@IProgressService private readonly progressService: IProgressService,
) {
super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService);
@@ -160,6 +168,15 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
}
}
private updateViewPaneClasses(fromEvent: boolean): void {
const welcomeEnabled = this.configurationService.getValue<boolean>(ChatConfiguration.ChatViewWelcomeEnabled) !== false;
this.viewPaneContainer?.classList.toggle('chat-view-welcome-enabled', welcomeEnabled);
if (fromEvent && this.lastDimensions) {
this.layoutBody(this.lastDimensions.height, this.lastDimensions.width);
}
}
private registerListeners(): void {
// Agent changes
@@ -200,6 +217,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
// Layout changes
this._register(Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration('workbench.sideBar.location'))(() => this.updateContextKeys(true)));
this._register(Event.filter(this.viewDescriptorService.onDidChangeContainerLocation, e => e.viewContainer === this.viewDescriptorService.getViewContainerByViewId(this.id))(() => this.updateContextKeys(true)));
// Settings changes
this._register(Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(ChatConfiguration.ChatViewWelcomeEnabled))(() => this.updateViewPaneClasses(true)));
}
private getTransferredOrPersistedSessionInfo(): { sessionId?: string; inputState?: IChatModelInputState; mode?: ChatModeKind } {
@@ -221,7 +241,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
} : undefined;
}
private async showModel(modelRef?: IChatModelReference | undefined): Promise<IChatModel | undefined> {
private async showModel(modelRef?: IChatModelReference | undefined, startNewSession = true): Promise<IChatModel | undefined> {
// Check if we're disposing a model with an active request
if (this.modelRef.value?.object.requestInProgress.get()) {
@@ -231,19 +251,26 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
this.modelRef.value = undefined;
const ref = modelRef ?? (this.chatService.transferredSessionData?.sessionId && this.chatService.transferredSessionData?.location === ChatAgentLocation.Chat
? await this.chatService.getOrRestoreSession(LocalChatSessionUri.forSession(this.chatService.transferredSessionData.sessionId))
: this.chatService.startSession(ChatAgentLocation.Chat));
if (!ref) {
throw new Error('Could not start chat session');
let ref: IChatModelReference | undefined;
if (startNewSession) {
ref = modelRef ?? (this.chatService.transferredSessionData?.sessionId && this.chatService.transferredSessionData?.location === ChatAgentLocation.Chat
? await this.chatService.getOrRestoreSession(LocalChatSessionUri.forSession(this.chatService.transferredSessionData.sessionId))
: this.chatService.startSession(ChatAgentLocation.Chat));
if (!ref) {
throw new Error('Could not start chat session');
}
}
this.modelRef.value = ref;
const model = ref.object;
const model = ref?.object;
// Update widget lock state based on session type
await this.updateWidgetLockState(model.sessionResource);
if (model) {
// Update widget lock state based on session type
await this.updateWidgetLockState(model.sessionResource);
this.viewState.sessionId = model.sessionId; // remember as model to restore in view state
}
this.viewState.sessionId = model.sessionId; // remember as model to restore in view state
this._widget.setModel(model);
// Update title control
@@ -273,6 +300,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
this.viewPaneContainer = parent;
this.viewPaneContainer.classList.add('chat-viewpane');
this.updateViewPaneClasses(false);
this.createControls(parent);
@@ -314,12 +342,12 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
const sessionsContainer = this.sessionsContainer = parent.appendChild($('.agent-sessions-container'));
// Sessions Title
const titleContainer = append(sessionsContainer, $('.agent-sessions-title-container'));
const title = append(titleContainer, $('span.agent-sessions-title'));
const sessionsTitleContainer = this.sessionsTitleContainer = append(sessionsContainer, $('.agent-sessions-title-container'));
const title = append(sessionsTitleContainer, $('span.agent-sessions-title'));
title.textContent = localize('recentSessions', "Recent Sessions");
// Sessions Toolbar
const toolbarContainer = append(titleContainer, $('.agent-sessions-toolbar'));
const toolbarContainer = append(sessionsTitleContainer, $('.agent-sessions-toolbar'));
this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, toolbarContainer, MenuId.AgentSessionsToolbar, {}));
// Sessions Control
@@ -327,10 +355,12 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
this.sessionsControl = this._register(this.instantiationService.createInstance(AgentSessionsControl, this.sessionsControlContainer, {
allowOpenSessionsInPanel: true,
filter: {
limitResults: ChatViewPane.SESSIONS_LIMIT,
limitResults: () => {
return that.sessionsViewerLimited ? ChatViewPane.SESSIONS_LIMIT : undefined;
},
exclude(session) {
if (session.isArchived()) {
return true; // exclude archived sessions
if (that.sessionsViewerLimited && session.isArchived()) {
return true; // exclude archived sessions when limited
}
return false;
@@ -344,6 +374,30 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
}
}));
this._register(this.onDidChangeBodyVisibility(visible => this.sessionsControl?.setVisible(visible)));
// Link to Sessions View
this.sessionsLinkContainer = append(sessionsContainer, $('.agent-sessions-link-container'));
const linkControl = this._register(this.instantiationService.createInstance(Link, this.sessionsLinkContainer, {
label: this.sessionsViewerLimited ? localize('showAllSessions', "Show All Sessions") : localize('showRecentSessions', "Limit to Recent Sessions"),
href: '',
}, {
opener: () => {
this.sessionsViewerLimited = !this.sessionsViewerLimited;
linkControl.link = {
label: this.sessionsViewerLimited ? localize('showAllSessions', "Show All Sessions") : localize('showRecentSessions', "Limit to Recent Sessions"),
href: ''
};
this.sessionsControl?.update();
if (this.lastDimensions) {
this.layoutBody(this.lastDimensions.height, this.lastDimensions.width);
}
this.sessionsControl?.focus();
}
}));
}
private notifySessionsControlChanged(newSessionsCount?: number): void {
@@ -446,7 +500,8 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
this.titleControl = this._register(this.instantiationService.createInstance(ChatViewTitleControl,
parent,
{
updateTitle: title => this.updateTitle(title)
updateTitle: title => this.updateTitle(title),
focusChat: () => this._widget.focusInput()
}
));
@@ -489,14 +544,26 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
this.updateActions();
}
async loadSession(sessionId: URI): Promise<IChatModel | undefined> {
const sessionType = getChatSessionType(sessionId);
if (sessionType !== localChatSessionType) {
await this.chatSessionsService.canResolveChatSession(sessionId);
}
async loadSession(sessionResource: URI): Promise<IChatModel | undefined> {
return this.progressService.withProgress({ location: ChatViewId, delay: 200 }, async () => {
let queue: Promise<void> = Promise.resolve();
const newModelRef = await this.chatService.loadSessionForResource(sessionId, ChatAgentLocation.Chat, CancellationToken.None);
return this.showModel(newModelRef);
// A delay here to avoid blinking because only Cloud sessions are slow, most others are fast
const clearWidget = disposableTimeout(() => {
// clear current model without starting a new one
queue = this.showModel(undefined, false).then(() => { });
}, 100);
const sessionType = getChatSessionType(sessionResource);
if (sessionType !== localChatSessionType) {
await this.chatSessionsService.canResolveChatSession(sessionResource);
}
const newModelRef = await this.chatService.loadSessionForResource(sessionResource, ChatAgentLocation.Chat, CancellationToken.None);
clearWidget.dispose();
await queue;
return this.showModel(newModelRef);
});
}
focusInput(): void {
@@ -533,7 +600,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
let heightReduction = 0;
let widthReduction = 0;
if (!this.sessionsContainer || !this.sessionsControlContainer || !this.sessionsControl || !this.viewPaneContainer) {
if (!this.sessionsContainer || !this.sessionsControlContainer || !this.sessionsControl || !this.viewPaneContainer || !this.sessionsTitleContainer || !this.sessionsLinkContainer) {
return { heightReduction, widthReduction };
}
@@ -554,9 +621,15 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
this.updateSessionsControlVisibility();
// Show as sidebar
const sessionsHeight = this.sessionsCount * AgentSessionsListDelegate.ITEM_HEIGHT;
if (this.sessionsViewerOrientation === AgentSessionsViewerOrientation.SideBySide) {
this.sessionsControlContainer.style.height = ``;
let sessionsHeight: number;
if (this.sessionsViewerLimited) {
sessionsHeight = this.sessionsCount * AgentSessionsListDelegate.ITEM_HEIGHT;
} else {
sessionsHeight = height - this.sessionsTitleContainer.offsetHeight - this.sessionsLinkContainer.offsetHeight;
}
this.sessionsControlContainer.style.height = `${sessionsHeight}px`;
this.sessionsControlContainer.style.width = `${ChatViewPane.SESSIONS_SIDEBAR_WIDTH}px`;
this.sessionsControl.layout(sessionsHeight, ChatViewPane.SESSIONS_SIDEBAR_WIDTH);
@@ -566,6 +639,13 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate {
// Show compact (grows with the number of items displayed)
else {
let sessionsHeight: number;
if (this.sessionsViewerLimited) {
sessionsHeight = this.sessionsCount * AgentSessionsListDelegate.ITEM_HEIGHT;
} else {
sessionsHeight = (ChatViewPane.SESSIONS_LIMIT + 2 /* TODO@bpasero revisit this hardcoded expansion */) * AgentSessionsListDelegate.ITEM_HEIGHT;
}
this.sessionsControlContainer.style.height = `${sessionsHeight}px`;
this.sessionsControlContainer.style.width = ``;
this.sessionsControl.layout(sessionsHeight, width);
@@ -3,11 +3,15 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { h } from '../../../../base/browser/dom.js';
import './media/chatViewTitleControl.css';
import { addDisposableListener, EventType, h } from '../../../../base/browser/dom.js';
import { renderAsPlaintext } from '../../../../base/browser/markdownRenderer.js';
import { Gesture, EventType as TouchEventType } from '../../../../base/browser/touch.js';
import { getBaseLayerHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegate2.js';
import { Emitter } from '../../../../base/common/event.js';
import { MarkdownString } from '../../../../base/common/htmlContent.js';
import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js';
import { ThemeIcon } from '../../../../base/common/themables.js';
import { MarshalledId } from '../../../../base/common/marshallingIds.js';
import { localize } from '../../../../nls.js';
import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js';
@@ -20,15 +24,16 @@ import { IChatViewTitleActionContext } from '../common/chatActions.js';
import { IChatModel } from '../common/chatModel.js';
import { ChatConfiguration } from '../common/constants.js';
import { ChatViewId } from './chat.js';
import './media/chatViewTitleControl.css';
import { AgentSessionProviders, getAgentSessionProviderIcon, getAgentSessionProviderName } from './agentSessions/agentSessions.js';
export interface IChatViewTitleDelegate {
updateTitle(title: string): void;
focusChat(): void;
}
export class ChatViewTitleControl extends Disposable {
private static readonly DEFAULT_TITLE = localize('chat', "Chat Session");
private static readonly DEFAULT_TITLE = localize('chat', "Chat");
private readonly _onDidChangeHeight = this._register(new Emitter<void>());
readonly onDidChangeHeight = this._onDidChangeHeight.event;
@@ -46,6 +51,7 @@ export class ChatViewTitleControl extends Disposable {
private titleContainer: HTMLElement | undefined;
private titleLabel: HTMLElement | undefined;
private titleIcon: HTMLElement | undefined;
private model: IChatModel | undefined;
private modelDisposables = this._register(new MutableDisposable());
@@ -91,16 +97,31 @@ export class ChatViewTitleControl extends Disposable {
const elements = h('div.chat-view-title-container', [
h('div.chat-view-title-toolbar@toolbar'),
h('span.chat-view-title-label@label'),
h('span.chat-view-title-icon@icon'),
]);
// Toolbar on the left
this.toolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, elements.toolbar, MenuId.ChatViewSessionTitleToolbar, {
menuOptions: { shouldForwardArgs: true },
hiddenItemStrategy: HiddenItemStrategy.NoHide
}));
// Title controls
this.titleContainer = elements.root;
this.titleLabel = elements.label;
this.titleIcon = elements.icon;
this._register(getBaseLayerHoverDelegate().setupDelayedHoverAtMouse(this.titleIcon, () => ({
content: this.getIconHoverContent() ?? '',
appearance: { compact: true }
})));
// Click to focus chat
this._register(Gesture.addTarget(this.titleContainer));
for (const eventType of [TouchEventType.Tap, EventType.CLICK]) {
this._register(addDisposableListener(this.titleContainer, eventType, () => {
this.delegate.focusChat();
}));
}
parent.appendChild(this.titleContainer);
}
@@ -124,6 +145,7 @@ export class ChatViewTitleControl extends Disposable {
this.delegate.updateTitle(this.getTitleWithPrefix());
this.updateTitle(this.title ?? ChatViewTitleControl.DEFAULT_TITLE);
this.updateIcon();
if (this.toolbar) {
this.toolbar.context = this.model && {
@@ -133,6 +155,41 @@ export class ChatViewTitleControl extends Disposable {
}
}
private updateIcon(): void {
if (!this.titleIcon) {
return;
}
const icon = this.getIcon();
if (icon) {
this.titleIcon.className = `chat-view-title-icon ${ThemeIcon.asClassName(icon)}`;
} else {
this.titleIcon.className = 'chat-view-title-icon';
}
}
private getIcon(): ThemeIcon | undefined {
const sessionType = this.model?.contributedChatSession?.chatSessionType;
switch (sessionType) {
case AgentSessionProviders.Background:
case AgentSessionProviders.Cloud:
return getAgentSessionProviderIcon(sessionType);
}
return undefined;
}
private getIconHoverContent(): string | undefined {
const sessionType = this.model?.contributedChatSession?.chatSessionType;
switch (sessionType) {
case AgentSessionProviders.Background:
case AgentSessionProviders.Cloud:
return localize('backgroundSession', "{0} Agent Session", getAgentSessionProviderName(sessionType));
}
return undefined;
}
private updateTitle(title: string): void {
if (!this.titleContainer || !this.titleLabel) {
return;
@@ -83,6 +83,7 @@ import { ChatInputPart, IChatInputPartOptions, IChatInputStyles } from './chatIn
import { ChatListDelegate, ChatListItemRenderer, IChatListItemTemplate, IChatRendererDelegate } from './chatListRenderer.js';
import { ChatEditorOptions } from './chatOptions.js';
import { ChatViewWelcomePart, IChatSuggestedPrompts, IChatViewWelcomeContent } from './viewsWelcome/chatViewWelcomeController.js';
import { IAgentSessionsService } from './agentSessions/agentSessionsService.js';
const $ = dom.$;
@@ -374,6 +375,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
@IChatLayoutService private readonly chatLayoutService: IChatLayoutService,
@IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService,
@IChatSessionsService private readonly chatSessionsService: IChatSessionsService,
@IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService,
@IChatTodoListService private readonly chatTodoListService: IChatTodoListService,
@IWorkspaceContextService private readonly contextService: IWorkspaceContextService,
@ILifecycleService private readonly lifecycleService: ILifecycleService
@@ -1364,6 +1366,8 @@ export class ChatWidget extends Disposable implements IChatWidget {
return;
}
const parentSessionResource = viewModel.sessionResource;
// Check if response is complete, not pending confirmation, and has no error
const checkIfShouldClear = (): boolean => {
const items = viewModel.getItems();
@@ -1377,6 +1381,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
if (checkIfShouldClear()) {
await this.clear();
this.archiveLocalParentSession(parentSessionResource);
return;
}
@@ -1400,9 +1405,18 @@ export class ChatWidget extends Disposable implements IChatWidget {
if (shouldClear) {
await this.clear();
this.archiveLocalParentSession(parentSessionResource);
}
}
private archiveLocalParentSession(sessionResource: URI): void {
if (sessionResource.scheme !== Schemas.vscodeLocalChatSession) {
return;
}
const session = this.agentSessionsService.model.sessions.find(candidate => isEqual(candidate.resource, sessionResource));
session?.setArchived(true);
}
setVisible(visible: boolean): void {
const wasVisible = this._visible;
this._visible = visible;
@@ -1954,6 +1968,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
if (!model) {
this.viewModel = undefined;
this.onDidChangeItems();
return;
}
@@ -755,7 +755,7 @@ have to be updated for changes to the rules above, or to support more deeply nes
border-radius: 4px;
padding: 0 6px 6px 6px;
/* top padding is inside the editor widget */
max-width: 100%;
width: 100%;
}
.interactive-input-part:has(.chat-editing-session > .chat-editing-session-container) .chat-input-container,
@@ -2107,6 +2107,41 @@ have to be updated for changes to the rules above, or to support more deeply nes
display: none;
}
.interactive-session .chat-list-divider {
display: flex;
align-items: center;
padding: 4px 3px 2px 3px;
font-size: 11px;
color: var(--vscode-descriptionForeground);
gap: 8px;
pointer-events: none;
user-select: none;
}
.interactive-session .monaco-list .monaco-list-row:has(.chat-list-divider) {
background-color: transparent !important;
cursor: default;
}
.interactive-session .chat-list-divider .chat-list-divider-label {
text-transform: uppercase;
letter-spacing: 0.04em;
flex-shrink: 0;
}
.interactive-session .chat-list-divider .chat-list-divider-line {
flex: 1;
height: 1px;
background-color: var(--vscode-editorWidget-border, var(--vscode-contrastBorder));
opacity: 0.5;
}
.interactive-session .chat-list-divider .chat-list-divider-toolbar {
display: flex;
align-items: center;
pointer-events: auto;
}
.interactive-session .chat-summary-list .monaco-list .monaco-list-row {
border-radius: 4px;
}
@@ -12,55 +12,11 @@
}
}
/* Sessions control: side by side */
.chat-viewpane.has-sessions-control.sessions-control-orientation-sidebyside {
display: flex;
&.sessions-control-position-left {
flex-direction: row;
}
&:not(.sessions-control-position-left) {
flex-direction: row-reverse;
}
}
/* Sessions control: compact */
.chat-viewpane.has-sessions-control:not(.sessions-control-orientation-sidebyside) {
display: flex;
flex-direction: column;
.agent-sessions-container {
margin: 12px 16px 32px 16px;
border-radius: 4px;
}
.agent-sessions-viewer .monaco-list:not(.element-focused):focus:before,
.agent-sessions-viewer .monaco-list-rows,
.agent-sessions-viewer .monaco-list-row:last-of-type {
/* Ensure the sessions list finishes with round borders at the bottom */
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
.interactive-session {
/* hide most welcome pieces (except suggested actions) when we show recent sessions to make some space */
.chat-welcome-view .chat-welcome-view-icon,
.chat-welcome-view .chat-welcome-view-title,
.chat-welcome-view .chat-welcome-view-message,
.chat-welcome-view .chat-welcome-view-disclaimer,
.chat-welcome-view .chat-welcome-view-tips {
visibility: hidden;
}
}
}
/* Sessions control: either sidebar or compact */
.chat-viewpane.has-sessions-control {
display: flex;
.chat-controls-container {
display: flex;
flex-direction: column;
@@ -82,10 +38,32 @@
color: var(--vscode-descriptionForeground);
padding: 8px;
.agent-sessions-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-sessions-toolbar {
visibility: hidden;
}
}
.agent-sessions-link-container {
padding: 8px 0;
font-size: 12px;
text-align: center;
}
.agent-sessions-link-container a {
color: var(--vscode-descriptionForeground);
}
.agent-sessions-link-container a:hover,
.agent-sessions-link-container a:active {
text-decoration: none;
color: var(--vscode-textLink-foreground);
}
}
.agent-sessions-container:hover .agent-sessions-title-container .agent-sessions-toolbar {
@@ -100,3 +78,36 @@
min-width: 0;
}
}
/* Sessions control: side by side */
.chat-viewpane.has-sessions-control.sessions-control-orientation-sidebyside {
&.sessions-control-position-left {
flex-direction: row;
}
&:not(.sessions-control-position-left) {
flex-direction: row-reverse;
}
}
/* Sessions control: compact */
.chat-viewpane.has-sessions-control:not(.sessions-control-orientation-sidebyside) {
flex-direction: column;
}
/* Welcome disabled */
.chat-viewpane:not(.chat-view-welcome-enabled) {
.interactive-session {
/* hide most welcome pieces (except suggested actions) when we show recent sessions to make some space */
.chat-welcome-view .chat-welcome-view-icon,
.chat-welcome-view .chat-welcome-view-title,
.chat-welcome-view .chat-welcome-view-message,
.chat-welcome-view .chat-welcome-view-disclaimer,
.chat-welcome-view .chat-welcome-view-tips {
visibility: hidden;
}
}
}
@@ -7,9 +7,10 @@
.chat-view-title-container {
display: none;
padding: 4px 8px 8px 16px;
padding: 8px 8px 8px 16px; /* try to align with the sessions view title */
border-bottom: 1px solid var(--vscode-sideBarSectionHeader-border);
align-items: center;
cursor: pointer;
.chat-view-title-label {
text-transform: uppercase;
@@ -19,6 +20,11 @@
white-space: nowrap;
text-overflow: ellipsis;
}
.chat-view-title-icon {
margin-left: auto;
color: var(--vscode-descriptionForeground);
}
}
.chat-view-title-container.visible {
@@ -95,6 +95,7 @@ export namespace ChatContextKeys {
export const agentSessionsViewerOrientation = new RawContextKey<number>('agentSessionsViewerOrientation', undefined, { type: 'number', description: localize('agentSessionsViewerOrientation', "Orientation of the agent sessions view in the chat view.") });
export const agentSessionsViewerPosition = new RawContextKey<number>('agentSessionsViewerPosition', undefined, { type: 'number', description: localize('agentSessionsViewerPosition', "Position of the agent sessions view in the chat view.") });
export const agentSessionType = new RawContextKey<string>('chatSessionType', '', { type: 'string', description: localize('agentSessionType', "The type of the current agent session item.") });
export const hasAgentSessionChanges = new RawContextKey<boolean>('chatSessionHasAgentChanges', false, { type: 'boolean', description: localize('chatSessionHasAgentChanges', "True when the current agent session item has changes.") });
export const isArchivedAgentSession = new RawContextKey<boolean>('agentIsArchived', false, { type: 'boolean', description: localize('agentIsArchived', "True when the agent session item is archived.") });
export const isActiveAgentSession = new RawContextKey<boolean>('agentIsActive', false, { type: 'boolean', description: localize('agentIsActive', "True when the agent session is currently active (not deletable).") });
@@ -122,6 +122,7 @@ export interface IChatContentReference {
options?: {
status?: { description: string; kind: ChatResponseReferencePartStatusKind };
diffMeta?: { added: number; removed: number };
originalUri?: URI;
};
kind: 'reference';
}
@@ -1053,6 +1054,7 @@ export interface IChatService {
logChatIndex(): void;
getLiveSessionItems(): Promise<IChatDetail[]>;
getHistorySessionItems(): Promise<IChatDetail[]>;
getMetadataForSession(sessionResource: URI): Promise<IChatDetail | undefined>;
readonly onDidPerformUserAction: Event<IChatUserActionEvent>;
notifyUserAction(event: IChatUserActionEvent): void;
@@ -39,7 +39,7 @@ import { ChatRequestParser } from './chatRequestParser.js';
import { ChatMcpServersStarting, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatModelReference, IChatProgress, IChatSendRequestData, IChatSendRequestOptions, IChatSendRequestResponseState, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatTransferredSessionData, IChatUserActionEvent } from './chatService.js';
import { ChatRequestTelemetry, ChatServiceTelemetry } from './chatServiceTelemetry.js';
import { IChatSessionsService } from './chatSessionsService.js';
import { ChatSessionStore, IChatTransfer2 } from './chatSessionStore.js';
import { ChatSessionStore, IChatSessionEntryMetadata, IChatTransfer2 } from './chatSessionStore.js';
import { IChatSlashCommandService } from './chatSlashCommands.js';
import { IChatTransferService } from './chatTransferService.js';
import { LocalChatSessionUri } from './chatUri.js';
@@ -153,6 +153,8 @@ export class ChatService extends Disposable implements IChatService {
} else if (this._saveModelsEnabled) {
await this._chatSessionStore.storeSessions([model]);
}
} else if (!localSessionId && model.getRequests().length > 0) {
await this._chatSessionStore.storeSessionsMetadataOnly([model]);
}
}
}));
@@ -217,10 +219,14 @@ export class ChatService extends Disposable implements IChatService {
return;
}
const liveChats = Array.from(this._sessionModels.values())
const liveLocalChats = Array.from(this._sessionModels.values())
.filter(session => this.shouldStoreSession(session));
this._chatSessionStore.storeSessions(liveChats);
this._chatSessionStore.storeSessions(liveLocalChats);
const liveNonLocalChats = Array.from(this._sessionModels.values())
.filter(session => !LocalChatSessionUri.parseLocalSessionId(session.sessionResource));
this._chatSessionStore.storeSessionsMetadataOnly(liveNonLocalChats);
}
/**
@@ -405,18 +411,32 @@ export class ChatService extends Disposable implements IChatService {
async getHistorySessionItems(): Promise<IChatDetail[]> {
const index = await this._chatSessionStore.getIndex();
return Object.values(index)
.filter(entry => !entry.isExternal)
.filter(entry => !this._sessionModels.has(LocalChatSessionUri.forSession(entry.sessionId)) && entry.initialLocation === ChatAgentLocation.Chat && !entry.isEmpty)
.map((entry): IChatDetail => {
const sessionResource = LocalChatSessionUri.forSession(entry.sessionId);
return ({
...entry,
sessionResource,
stats: entry.stats,
isActive: this._sessionModels.has(sessionResource),
});
});
}
async getMetadataForSession(sessionResource: URI): Promise<IChatDetail | undefined> {
const index = await this._chatSessionStore.getIndex();
const metadata: IChatSessionEntryMetadata | undefined = index[sessionResource.toString()];
if (metadata) {
return {
...metadata,
sessionResource,
isActive: this._sessionModels.has(sessionResource),
};
}
return undefined;
}
private shouldBeInHistory(entry: ChatModel): boolean {
return !entry.isImported && !!LocalChatSessionUri.parseLocalSessionId(entry.sessionResource) && entry.initialLocation === ChatAgentLocation.Chat;
}
@@ -24,6 +24,7 @@ import { awaitStatsForSession } from './chat.js';
import { ModifiedFileEntryState } from './chatEditingService.js';
import { ChatModel, IChatModelInputState, ISerializableChatData, ISerializableChatDataIn, ISerializableChatsData, normalizeSerializableChatData } from './chatModel.js';
import { IChatSessionStats } from './chatService.js';
import { LocalChatSessionUri } from './chatUri.js';
import { ChatAgentLocation } from './constants.js';
const maxPersistedSessions = 25;
@@ -102,6 +103,27 @@ export class ChatSessionStore extends Disposable {
}
}
async storeSessionsMetadataOnly(sessions: ChatModel[]): Promise<void> {
if (this.shuttingDown) {
// Don't start this task if we missed the chance to block shutdown
return;
}
try {
this.storeTask = this.storeQueue.queue(async () => {
try {
await Promise.all(sessions.map(session => this.writeSessionMetadataOnly(session)));
await this.flushIndex();
} catch (e) {
this.reportError('storeSessions', 'Error storing chat sessions', e);
}
});
await this.storeTask;
} finally {
this.storeTask = undefined;
}
}
// async storeTransferSession(transferData: IChatTransfer, session: ISerializableChatData): Promise<void> {
// try {
// const content = JSON.stringify(session, undefined, 2);
@@ -144,6 +166,23 @@ export class ChatSessionStore extends Disposable {
}
}
private async writeSessionMetadataOnly(session: ChatModel): Promise<void> {
// Only to be used for external sessions
if (LocalChatSessionUri.parseLocalSessionId(session.sessionResource)) {
return;
}
try {
const index = this.internalGetIndex();
// TODO get this class on sessionResource
const externalSessionId = session.sessionResource.toString();
index.entries[externalSessionId] = await getSessionMetadata(session);
} catch (e) {
this.reportError('sessionMetadataWrite', 'Error writing chat session metadata', e);
}
}
private async flushIndex(): Promise<void> {
const index = this.internalGetIndex();
try {
@@ -163,6 +202,7 @@ export class ChatSessionStore extends Disposable {
private async trimEntries(): Promise<void> {
const index = this.internalGetIndex();
const entries = Object.entries(index.entries)
.filter(([_id, entry]) => !entry.isExternal)
.sort((a, b) => b[1].lastMessageDate - a[1].lastMessageDate)
.map(([id]) => id);
@@ -400,6 +440,11 @@ export interface IChatSessionEntryMetadata {
* filter the old ones out of history.
*/
isEmpty?: boolean;
/**
* Whether this session was loaded from an external provider (eg background/cloud sessions).
*/
isExternal?: boolean;
}
function isChatSessionEntryMetadata(obj: unknown): obj is IChatSessionEntryMetadata {
@@ -459,7 +504,8 @@ async function getSessionMetadata(session: ChatModel | ISerializableChatData): P
initialLocation: session.initialLocation,
hasPendingEdits: session instanceof ChatModel ? (session.editingSession?.entries.get().some(e => e.state.get() === ModifiedFileEntryState.Modified)) : false,
isEmpty: session instanceof ChatModel ? session.getRequests().length === 0 : session.requests.length === 0,
stats
stats,
isExternal: session instanceof ChatModel && !LocalChatSessionUri.parseLocalSessionId(session.sessionResource)
};
}
@@ -73,17 +73,24 @@ export interface IChatSessionItem {
startTime: number;
endTime?: number;
};
statistics?: {
changes?: {
files: number;
insertions: number;
deletions: number;
};
} | readonly IChatSessionFileChange[];
archived?: boolean;
// TODO:@osortega remove once the single-view is default
/** @deprecated */
history?: boolean;
}
export interface IChatSessionFileChange {
modifiedUri: URI;
originalUri?: URI;
insertions: number;
deletions: number;
}
export type IChatSessionHistoryItem = {
id?: string;
type: 'request';
@@ -150,7 +157,7 @@ export interface IChatSessionContentProvider {
export type SessionOptionsChangedCallback = (sessionResource: URI, updates: ReadonlyArray<{
optionId: string;
value: string;
value: string | IChatSessionProviderOptionItem;
}>) => Promise<void>;
export interface IChatSessionsService {
@@ -203,7 +210,7 @@ export interface IChatSessionsService {
/**
* Fired when options for a chat session change.
*/
onDidChangeSessionOptions: Event<{ readonly resource: URI; readonly updates: ReadonlyArray<{ optionId: string; value: string }> }>;
onDidChangeSessionOptions: Event<URI>;
/**
* Get the capabilities for a specific session type
@@ -213,7 +220,7 @@ export interface IChatSessionsService {
getOptionGroupsForSessionType(chatSessionType: string): IChatSessionProviderOptionGroup[] | undefined;
setOptionGroupsForSessionType(chatSessionType: string, handle: number, optionGroups?: IChatSessionProviderOptionGroup[]): void;
setOptionsChangeCallback(callback: SessionOptionsChangedCallback): void;
notifySessionOptionsChange(sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string }>): Promise<void>;
notifySessionOptionsChange(sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>): Promise<void>;
// Editable session support
setEditableSession(sessionResource: URI, data: IEditableData | null): Promise<void>;
@@ -27,6 +27,7 @@ export enum ChatConfiguration {
NotifyWindowOnResponseReceived = 'chat.notifyWindowOnResponseReceived',
ChatViewRecentSessionsEnabled = 'chat.recentSessions.enabled',
ChatViewTitleEnabled = 'chat.viewTitle.enabled',
ChatViewWelcomeEnabled = 'chat.viewWelcome.enabled',
SubagentToolCustomAgents = 'chat.customAgentInSubagent.enabled',
ShowCodeBlockProgressAnimation = 'chat.agent.codeBlockProgress',
RestoreLastPanelSession = 'chat.restoreLastPanelSession',
@@ -16,7 +16,7 @@ import { IModelService } from '../../../../../../editor/common/services/model.js
import { localize } from '../../../../../../nls.js';
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
import { IExtensionDescription } from '../../../../../../platform/extensions/common/extensions.js';
import { IFileService } from '../../../../../../platform/files/common/files.js';
import { FileOperationError, FileOperationResult, IFileService } from '../../../../../../platform/files/common/files.js';
import { IExtensionService } from '../../../../../services/extensions/common/extensions.js';
import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js';
import { ILabelService } from '../../../../../../platform/label/common/label.js';
@@ -387,7 +387,7 @@ export class PromptsService extends Disposable implements IPromptsService {
let agentFiles = await this.listPromptFiles(PromptsType.agent, token);
const disabledAgents = this.getDisabledPromptFiles(PromptsType.agent);
agentFiles = agentFiles.filter(promptPath => !disabledAgents.has(promptPath.uri));
const customAgents = await Promise.all(
const customAgentsResults = await Promise.allSettled(
agentFiles.map(async (promptPath): Promise<ICustomAgent> => {
const uri = promptPath.uri;
const ast = await this.parseNew(uri, token);
@@ -432,6 +432,23 @@ export class PromptsService extends Disposable implements IPromptsService {
return { uri, name, description, model, tools, handOffs, argumentHint, target, infer, agentInstructions, source };
})
);
const customAgents: ICustomAgent[] = [];
for (let i = 0; i < customAgentsResults.length; i++) {
const result = customAgentsResults[i];
if (result.status === 'fulfilled') {
customAgents.push(result.value);
} else {
const uri = agentFiles[i].uri;
const error = result.reason;
if (error instanceof FileOperationError && error.fileOperationResult === FileOperationResult.FILE_NOT_FOUND) {
this.logger.warn(`[computeCustomAgents] Skipping agent file that does not exist: ${uri}`, error.message);
} else {
this.logger.error(`[computeCustomAgents] Failed to parse agent file: ${uri}`, error);
}
}
}
return customAgents;
}
@@ -79,14 +79,6 @@ const languageModelToolsExtensionPoint = extensionsRegistry.ExtensionsRegistry.r
type: 'string',
pattern: '^[\\w-]+$'
},
legacyToolReferenceFullNames: {
markdownDescription: localize('legacyToolReferenceFullNames', "An array of deprecated names for backwards compatibility that can also be used to reference this tool in a query. Each name must not contain whitespace. Full names are generally in the format `toolsetName/toolReferenceName` (e.g., `search/readFile`) or just `toolReferenceName` when there is no toolset (e.g., `readFile`)."),
type: 'array',
items: {
type: 'string',
pattern: '^[\\w-]+(/[\\w-]+)?$'
}
},
displayName: {
description: localize('toolDisplayName', "A human-readable name for this tool that may be used to describe it in the UI."),
type: 'string'
@@ -179,14 +171,6 @@ const languageModelToolSetsExtensionPoint = extensionsRegistry.ExtensionsRegistr
type: 'string',
pattern: '^[\\w-]+$'
},
legacyFullNames: {
markdownDescription: localize('toolSetLegacyFullNames', "An array of deprecated names for backwards compatibility that can also be used to reference this tool set. Each name must not contain whitespace. Full names are generally in the format `parentToolSetName/toolSetName` (e.g., `github/repo`) or just `toolSetName` when there is no parent toolset (e.g., `repo`)."),
type: 'array',
items: {
type: 'string',
pattern: '^[\\w-]+$'
}
},
description: {
description: localize('toolSetDescription', "A description of this tool set."),
type: 'string'
@@ -191,7 +191,7 @@ suite('Agent Sessions', () => {
tooltip: 'Session tooltip',
iconPath: ThemeIcon.fromId('check'),
timing: { startTime, endTime },
statistics: { files: 1, insertions: 10, deletions: 5 }
changes: { files: 1, insertions: 10, deletions: 5, details: [] }
}
]
};
@@ -212,7 +212,7 @@ suite('Agent Sessions', () => {
assert.strictEqual(session.status, ChatSessionStatus.Completed);
assert.strictEqual(session.timing.startTime, startTime);
assert.strictEqual(session.timing.endTime, endTime);
assert.deepStrictEqual(session.statistics, { files: 1, insertions: 10, deletions: 5 });
assert.deepStrictEqual(session.changes, { files: 1, insertions: 10, deletions: 5 });
});
});
@@ -181,6 +181,10 @@ class MockChatService implements IChatService {
waitForModelDisposals(): Promise<void> {
return Promise.resolve();
}
getMetadataForSession(sessionResource: URI): Promise<IChatDetail | undefined> {
throw new Error('Method not implemented.');
}
}
function createMockChatModel(options: {
@@ -528,10 +532,11 @@ suite('LocalAgentsSessionsProvider', () => {
const sessions = await provider.provideChatSessionItems(CancellationToken.None);
assert.strictEqual(sessions.length, 1);
assert.ok(sessions[0].statistics);
assert.strictEqual(sessions[0].statistics?.files, 2);
assert.strictEqual(sessions[0].statistics?.insertions, 30);
assert.strictEqual(sessions[0].statistics?.deletions, 8);
assert.ok(sessions[0].changes);
const changes = sessions[0].changes as { files: number; insertions: number; deletions: number };
assert.strictEqual(changes.files, 2);
assert.strictEqual(changes.insertions, 30);
assert.strictEqual(changes.deletions, 8);
});
});
@@ -565,7 +570,7 @@ suite('LocalAgentsSessionsProvider', () => {
const sessions = await provider.provideChatSessionItems(CancellationToken.None);
assert.strictEqual(sessions.length, 1);
assert.strictEqual(sessions[0].statistics, undefined);
assert.strictEqual(sessions[0].changes, undefined);
});
});
});
@@ -146,4 +146,7 @@ export class MockChatService implements IChatService {
waitForModelDisposals(): Promise<void> {
throw new Error('Method not implemented.');
}
getMetadataForSession(sessionResource: URI): Promise<IChatDetail | undefined> {
throw new Error('Method not implemented.');
}
}
@@ -18,7 +18,7 @@ import { IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessi
export class MockChatSessionsService implements IChatSessionsService {
_serviceBrand: undefined;
private readonly _onDidChangeSessionOptions = new Emitter<{ readonly resource: URI; readonly updates: ReadonlyArray<{ optionId: string; value: string }> }>();
private readonly _onDidChangeSessionOptions = new Emitter<URI>();
readonly onDidChangeSessionOptions = this._onDidChangeSessionOptions.event;
private readonly _onDidChangeItemsProviders = new Emitter<IChatSessionItemProvider>();
readonly onDidChangeItemsProviders = this._onDidChangeItemsProviders.event;
@@ -1202,6 +1202,57 @@ suite('PromptsService', () => {
registered.dispose();
});
test('Contributed agent file that does not exist should not crash', async () => {
const nonExistentUri = URI.parse('file://extensions/my-extension/nonexistent.agent.md');
const existingUri = URI.parse('file://extensions/my-extension/existing.agent.md');
const extension = {
identifier: { value: 'test.my-extension' }
} as unknown as IExtensionDescription;
// Only create the existing file
await mockFiles(fileService, [
{
path: existingUri.path,
contents: [
'---',
'name: \'Existing Agent\'',
'description: \'An agent that exists\'',
'---',
'I am an existing agent.',
]
}
]);
// Register both agents (one exists, one doesn't)
const registered1 = service.registerContributedFile(
PromptsType.agent,
'NonExistent Agent',
'An agent that does not exist',
nonExistentUri,
extension
);
const registered2 = service.registerContributedFile(
PromptsType.agent,
'Existing Agent',
'An agent that exists',
existingUri,
extension
);
// Verify that getCustomAgents doesn't crash and returns only the valid agent
const agents = await service.getCustomAgents(CancellationToken.None);
// Should only get the existing agent, not the non-existent one
assert.strictEqual(agents.length, 1, 'Should only return the agent that exists');
assert.strictEqual(agents[0].name, 'Existing Agent');
assert.strictEqual(agents[0].description, 'An agent that exists');
assert.strictEqual(agents[0].uri.toString(), existingUri.toString());
registered1.dispose();
registered2.dispose();
});
});
suite('findClaudeSkills', () => {
@@ -88,6 +88,8 @@ import { CTX_INLINE_CHAT_RESPONSE_TYPE, InlineChatConfigKeys, InlineChatResponse
import { TestWorkerService } from './testWorkerService.js';
import { MockChatSessionsService } from '../../../chat/test/common/mockChatSessionsService.js';
import { IChatSessionsService } from '../../../chat/common/chatSessionsService.js';
import { IAgentSessionsService } from '../../../chat/browser/agentSessions/agentSessionsService.js';
import { IAgentSessionsModel } from '../../../chat/browser/agentSessions/agentSessionsModel.js';
suite('InlineChatController', function () {
@@ -236,6 +238,17 @@ suite('InlineChatController', function () {
}],
[IChatEntitlementService, new SyncDescriptor(TestChatEntitlementService)],
[IChatSessionsService, new SyncDescriptor(MockChatSessionsService)],
[IAgentSessionsService, new class extends mock<IAgentSessionsService>() {
override get model(): IAgentSessionsModel {
return {
onWillResolve: Event.None,
onDidResolve: Event.None,
onDidChangeSessions: Event.None,
sessions: [],
resolve: async () => { }
} as IAgentSessionsModel;
}
}],
);
instaService = store.add((store.add(workbenchInstantiationService(undefined, store))).createChild(serviceCollection));
@@ -5,11 +5,15 @@
import * as DOM from '../../../../base/browser/dom.js';
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
import { MultiDiffEditorWidget } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js';
import { IResourceLabel, IWorkbenchUIElementFactory } from '../../../../editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.js';
import { ITextResourceConfigurationService } from '../../../../editor/common/services/textResourceConfiguration.js';
import { FloatingClickMenu } from '../../../../platform/actions/browser/floatingMenu.js';
import { IMenuService, MenuId } from '../../../../platform/actions/common/actions.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { InstantiationService } from '../../../../platform/instantiation/common/instantiationService.js';
import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.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';
@@ -29,12 +33,15 @@ import { IDiffEditor } from '../../../../editor/common/editorCommon.js';
import { Range } from '../../../../editor/common/core/range.js';
import { MultiDiffEditorItem } from './multiDiffSourceResolverService.js';
import { IEditorProgressService } from '../../../../platform/progress/common/progress.js';
import { ResourceContextKey } from '../../../common/contextkeys.js';
export class MultiDiffEditor extends AbstractEditorWithViewState<IMultiDiffEditorViewState> {
static readonly ID = 'multiDiffEditor';
private _multiDiffEditorWidget: MultiDiffEditorWidget | undefined = undefined;
private _viewModel: MultiDiffEditorViewModel | undefined;
private _sessionResourceContextKey: ResourceContextKey | undefined;
private _contentOverlay: MultiDiffEditorContentMenuOverlay | undefined;
public get viewModel(): MultiDiffEditorViewModel | undefined {
return this._viewModel;
@@ -50,6 +57,7 @@ export class MultiDiffEditor extends AbstractEditorWithViewState<IMultiDiffEdito
@IEditorGroupsService editorGroupService: IEditorGroupsService,
@ITextResourceConfigurationService textResourceConfigurationService: ITextResourceConfigurationService,
@IEditorProgressService private editorProgressService: IEditorProgressService,
@IMenuService private readonly menuService: IMenuService,
) {
super(
MultiDiffEditor.ID,
@@ -75,11 +83,24 @@ export class MultiDiffEditor extends AbstractEditorWithViewState<IMultiDiffEdito
this._register(this._multiDiffEditorWidget.onDidChangeActiveControl(() => {
this._onDidChangeControl.fire();
}));
const scopedContextKeyService = this._multiDiffEditorWidget.getContextKeyService();
const scopedInstantiationService = this._multiDiffEditorWidget.getScopedInstantiationService();
this._sessionResourceContextKey = this._register(scopedInstantiationService.createInstance(ResourceContextKey));
this._contentOverlay = this._register(new MultiDiffEditorContentMenuOverlay(
this._multiDiffEditorWidget.getRootElement(),
this._sessionResourceContextKey,
scopedContextKeyService,
this.menuService,
scopedInstantiationService,
));
}
override async setInput(input: MultiDiffEditorInput, options: IMultiDiffEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise<void> {
await super.setInput(input, options, context, token);
this._viewModel = await input.getViewModel();
this._sessionResourceContextKey?.set(input.resource);
this._contentOverlay?.updateResource(input.resource);
this._multiDiffEditorWidget!.setViewModel(this._viewModel);
const viewState = this.loadEditorViewState(input, context);
@@ -106,6 +127,8 @@ export class MultiDiffEditor extends AbstractEditorWithViewState<IMultiDiffEdito
override async clearInput(): Promise<void> {
await super.clearInput();
this._sessionResourceContextKey?.set(null);
this._contentOverlay?.updateResource(undefined);
this._multiDiffEditorWidget!.setViewModel(undefined);
}
@@ -163,6 +186,59 @@ export class MultiDiffEditor extends AbstractEditorWithViewState<IMultiDiffEdito
}
}
class MultiDiffEditorContentMenuOverlay extends Disposable {
private readonly overlayStore = this._register(new MutableDisposable<DisposableStore>());
private readonly resourceContextKey: ResourceContextKey;
private currentResource: URI | undefined;
private readonly rebuild: () => void;
constructor(
root: HTMLElement,
resourceContextKey: ResourceContextKey,
contextKeyService: IContextKeyService,
menuService: IMenuService,
instantiationService: IInstantiationService,
) {
super();
this.resourceContextKey = resourceContextKey;
const menu = this._register(menuService.createMenu(MenuId.MultiDiffEditorContent, contextKeyService));
this.rebuild = () => {
this.overlayStore.clear();
const container = DOM.h('div.floating-menu-overlay-widget.multi-diff-root-floating-menu');
root.appendChild(container.root);
const floatingMenu = instantiationService.createInstance(FloatingClickMenu, {
container: container.root,
menuId: MenuId.MultiDiffEditorContent,
getActionArg: () => this.currentResource,
});
const store = new DisposableStore();
store.add(floatingMenu);
store.add(toDisposable(() => container.root.remove()));
this.overlayStore.value = store;
};
this.rebuild();
this._register(menu.onDidChange(() => {
this.overlayStore.clear();
this.rebuild();
}));
this._register(resourceContextKey);
}
public updateResource(resource: URI | undefined): void {
this.currentResource = resource;
// Update context key and rebuild so menu arg matches
this.resourceContextKey.set(resource ?? null);
this.overlayStore.clear();
this.rebuild();
}
}
class WorkbenchUIElementFactory implements IWorkbenchUIElementFactory {
constructor(
@@ -15,6 +15,7 @@ import { DisposableStore, IDisposable } from '../../../../base/common/lifecycle.
import { IMarkdownString, isEmptyMarkdownString, isMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js';
import { ThemeIcon } from '../../../../base/common/themables.js';
import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js';
import { findLastIdx } from '../../../../base/common/arraysFind.js';
export const SWIMLANE_HEIGHT = 22;
export const SWIMLANE_WIDTH = 11;
@@ -301,20 +302,10 @@ export function toISCMHistoryItemViewModelArray(
let colorIndex = -1;
const viewModels: ISCMHistoryItemViewModel[] = [];
// Add incoming/outgoing changes history items
addIncomingOutgoingChangesHistoryItems(
historyItems,
currentHistoryItemRef,
currentHistoryItemRemoteRef,
addIncomingChanges,
addOutgoingChanges,
mergeBase
);
for (let index = 0; index < historyItems.length; index++) {
const historyItem = historyItems[index];
const kind = getHistoryItemViewModelKind(historyItem, currentHistoryItemRef);
const kind = historyItem.id === currentHistoryItemRef?.revision ? 'HEAD' : 'node';
const outputSwimlanesFromPreviousItem = viewModels.at(-1)?.outputSwimlanes ?? [];
const inputSwimlanes = outputSwimlanesFromPreviousItem.map(i => deepClone(i));
const outputSwimlanes: ISCMHistoryItemGraphNode[] = [];
@@ -398,6 +389,20 @@ export function toISCMHistoryItemViewModelArray(
} satisfies ISCMHistoryItemViewModel);
}
// Add incoming/outgoing changes history item view models. While working
// with the view models is a little bit more complex, we are doing this
// after creating the view models so that we can use the swimlane colors
// to add the incoming/outgoing changes history items view models to the
// correct swimlanes.
addIncomingOutgoingChangesHistoryItems(
viewModels,
currentHistoryItemRef,
currentHistoryItemRemoteRef,
addIncomingChanges,
addOutgoingChanges,
mergeBase
);
return viewModels;
}
@@ -412,94 +417,118 @@ export function getHistoryItemIndex(historyItemViewModel: ISCMHistoryItemViewMod
return inputIndex !== -1 ? inputIndex : inputSwimlanes.length;
}
function getHistoryItemViewModelKind(historyItem: ISCMHistoryItem, currentHistoryItemRef?: ISCMHistoryItemRef): 'HEAD' | 'node' | 'incoming-changes' | 'outgoing-changes' {
switch (historyItem.id) {
case currentHistoryItemRef?.revision:
return 'HEAD';
case SCMIncomingHistoryItemId:
return 'incoming-changes';
case SCMOutgoingHistoryItemId:
return 'outgoing-changes';
default:
return 'node';
}
}
function addIncomingOutgoingChangesHistoryItems(
historyItems: ISCMHistoryItem[],
viewModels: ISCMHistoryItemViewModel[],
currentHistoryItemRef?: ISCMHistoryItemRef,
currentHistoryItemRemoteRef?: ISCMHistoryItemRef,
addIncomingChanges?: boolean,
addOutgoingChanges?: boolean,
mergeBase?: string
): void {
if (historyItems.length > 0 && mergeBase && currentHistoryItemRef?.revision !== currentHistoryItemRemoteRef?.revision) {
// Outgoing changes history item
if (addOutgoingChanges && currentHistoryItemRef?.revision && currentHistoryItemRef.revision !== mergeBase) {
const currentHistoryItemIndex = historyItems.findIndex(h => h.id === currentHistoryItemRef.revision);
if (currentHistoryItemIndex !== -1) {
// Insert outgoing history item
historyItems.splice(currentHistoryItemIndex, 0, {
id: SCMOutgoingHistoryItemId,
displayId: '0'.repeat(historyItems[0].displayId?.length ?? 0),
parentIds: [currentHistoryItemRef.revision],
author: currentHistoryItemRef?.name,
subject: localize('outgoingChanges', 'Outgoing Changes'),
message: ''
} satisfies ISCMHistoryItem);
}
}
// Incoming changes history item
if (currentHistoryItemRef?.revision !== currentHistoryItemRemoteRef?.revision && mergeBase) {
// Incoming changes node
if (addIncomingChanges && currentHistoryItemRemoteRef && currentHistoryItemRemoteRef.revision !== mergeBase) {
// Start from the current history item remote ref and walk towards the merge base.
const currentHistoryItemRemoteIndex = historyItems
.findIndex(h => h.id === currentHistoryItemRemoteRef.revision);
// Find the before/after indices using the merge base (might not be present if the merge base history item is not loaded yet)
const beforeHistoryItemIndex = findLastIdx(viewModels, vm => vm.outputSwimlanes.some(node => node.id === mergeBase));
const afterHistoryItemIndex = viewModels.findIndex(vm => vm.historyItem.id === mergeBase);
let historyItemIndex = -1;
if (currentHistoryItemRemoteIndex !== -1) {
let historyItemParentId = historyItems[currentHistoryItemRemoteIndex].parentIds[0];
for (let index = currentHistoryItemRemoteIndex; index < historyItems.length; index++) {
if (historyItems[index].parentIds.includes(mergeBase)) {
historyItemIndex = index;
break;
}
if (historyItems[index].parentIds.includes(historyItemParentId)) {
historyItemParentId = historyItems[index].parentIds[0];
}
}
}
if (historyItemIndex !== -1 && historyItemIndex < historyItems.length - 1) {
if (beforeHistoryItemIndex !== -1 && afterHistoryItemIndex !== -1) {
// There is a known edge case in which the incoming changes have already
// been merged. For this scenario, we will not be showing the incoming
// changes history item. https://github.com/microsoft/vscode/issues/276064
const incomingChangeMerged = historyItems[historyItemIndex].parentIds.length === 2 &&
historyItems[historyItemIndex].parentIds.includes(mergeBase);
const incomingChangeMerged = viewModels[beforeHistoryItemIndex].historyItem.parentIds.length === 2 &&
viewModels[beforeHistoryItemIndex].historyItem.parentIds.includes(mergeBase);
if (!incomingChangeMerged) {
// Insert incoming history item after the history item
historyItems.splice(historyItemIndex + 1, 0, {
// Update the before node so that the incoming and outgoing swimlanes
// point to the `incoming-changes` node instead of the merge base
viewModels[beforeHistoryItemIndex] = {
...viewModels[beforeHistoryItemIndex],
inputSwimlanes: viewModels[beforeHistoryItemIndex].inputSwimlanes
.map(node => {
return node.id === mergeBase && node.color === historyItemRemoteRefColor
? { ...node, id: SCMIncomingHistoryItemId }
: node;
}),
outputSwimlanes: viewModels[beforeHistoryItemIndex].outputSwimlanes
.map(node => {
return node.id === mergeBase && node.color === historyItemRemoteRefColor
? { ...node, id: SCMIncomingHistoryItemId }
: node;
})
};
// Create incoming changes node
const inputSwimlanes = viewModels[beforeHistoryItemIndex].outputSwimlanes.map(i => deepClone(i));
const outputSwimlanes = viewModels[afterHistoryItemIndex].inputSwimlanes.map(i => deepClone(i));
const displayIdLength = viewModels[0].historyItem.displayId?.length ?? 0;
const incomingChangesHistoryItem = {
id: SCMIncomingHistoryItemId,
displayId: '0'.repeat(historyItems[0].displayId?.length ?? 0),
parentIds: historyItems[historyItemIndex].parentIds.slice(),
displayId: '0'.repeat(displayIdLength),
parentIds: [mergeBase],
author: currentHistoryItemRemoteRef?.name,
subject: localize('incomingChanges', 'Incoming Changes'),
message: ''
} satisfies ISCMHistoryItem);
// Update the history item to point to incoming changes history item
historyItems[historyItemIndex] = {
...historyItems[historyItemIndex],
parentIds: historyItems[historyItemIndex].parentIds.map(id => {
return id === mergeBase ? SCMIncomingHistoryItemId : id;
})
} satisfies ISCMHistoryItem;
// Insert incoming changes node
viewModels.splice(afterHistoryItemIndex, 0, {
historyItem: incomingChangesHistoryItem,
kind: 'incoming-changes',
inputSwimlanes,
outputSwimlanes
});
}
}
}
// Outgoing changes node
if (addOutgoingChanges && currentHistoryItemRef?.revision && currentHistoryItemRef.revision !== mergeBase) {
// Find the before/after indices using the merge base (might not be present if the current history item is not loaded yet)
let beforeHistoryItemIndex = findLastIdx(viewModels, vm => vm.outputSwimlanes.some(node => node.id === currentHistoryItemRef.revision));
const afterHistoryItemIndex = viewModels.findIndex(vm => vm.historyItem.id === currentHistoryItemRef.revision);
if (afterHistoryItemIndex !== -1) {
if (beforeHistoryItemIndex === -1 && afterHistoryItemIndex > 0) {
beforeHistoryItemIndex = afterHistoryItemIndex - 1;
}
// Update the after node to point to the `outgoing-changes` node
viewModels[afterHistoryItemIndex].inputSwimlanes.push({
id: currentHistoryItemRef.revision,
color: historyItemRefColor
});
const inputSwimlanes = beforeHistoryItemIndex !== -1
? viewModels[beforeHistoryItemIndex].outputSwimlanes
.map(node => {
return addIncomingChanges && node.id === mergeBase && node.color === historyItemRemoteRefColor
? { ...node, id: SCMIncomingHistoryItemId }
: node;
})
: [];
const outputSwimlanes = viewModels[afterHistoryItemIndex].inputSwimlanes.slice(0);
const displayIdLength = viewModels[0].historyItem.displayId?.length ?? 0;
const outgoingChangesHistoryItem = {
id: SCMOutgoingHistoryItemId,
displayId: '0'.repeat(displayIdLength),
parentIds: [mergeBase],
author: currentHistoryItemRef?.name,
subject: localize('outgoingChanges', 'Outgoing Changes'),
message: ''
} satisfies ISCMHistoryItem;
// Insert outgoing changes node
viewModels.splice(afterHistoryItemIndex, 0, {
historyItem: outgoingChangesHistoryItem,
kind: 'outgoing-changes',
inputSwimlanes,
outputSwimlanes
});
}
}
}
}
@@ -603,7 +603,7 @@ suite('toISCMHistoryItemViewModelArray', () => {
* * e(f)
* * f(g)
*/
test('graph with incoming/outgoing changes (remote ref first)', () => {
test.skip('graph with incoming/outgoing changes (remote ref first)', () => {
const models = [
toSCMHistoryItem('a', ['b'], [{ id: 'origin/main', name: 'origin/main' }]),
toSCMHistoryItem('b', ['e']),
@@ -8,6 +8,7 @@ import type { IMarker as IXtermMarker } from '@xterm/xterm';
import type { ITerminalCommand } from '../../../../platform/terminal/common/capabilities/capabilities.js';
import { ITerminalService, type IDetachedTerminalInstance } from './terminal.js';
import { DetachedProcessInfo } from './detachedTerminal.js';
import { TerminalCapabilityStore } from '../../../../platform/terminal/common/capabilities/terminalCapabilityStore.js';
import { XtermTerminal } from './xterm/xtermTerminal.js';
import { TERMINAL_BACKGROUND_COLOR } from '../common/terminalColorRegistry.js';
import { PANEL_BACKGROUND } from '../../../common/theme.js';
@@ -132,12 +133,15 @@ export class DetachedTerminalCommandMirror extends Disposable implements IDetach
}
private async _createTerminal(): Promise<IDetachedTerminalInstance> {
const processInfo = this._register(new DetachedProcessInfo({ initialCwd: '' }));
const capabilities = this._register(new TerminalCapabilityStore());
const detached = await this._terminalService.createDetachedTerminal({
cols: this._xtermTerminal.raw!.cols,
rows: 10,
readonly: true,
processInfo: new DetachedProcessInfo({ initialCwd: '' }),
processInfo,
disableOverviewRuler: true,
capabilities,
colorProvider: {
getBackgroundColor: theme => {
const terminalBackground = theme.getColor(TERMINAL_BACKGROUND_COLOR);
@@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import { Codicon } from '../../../../../base/common/codicons.js';
import { DisposableStore } from '../../../../../base/common/lifecycle.js';
import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js';
import { localize, localize2 } from '../../../../../nls.js';
import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js';
@@ -409,7 +410,9 @@ registerAction2(class ShowChatTerminalsAction extends Action2 {
qp.title = localize2('showChatTerminals.title', 'Chat Terminals').value;
qp.matchOnDescription = true;
qp.matchOnDetail = true;
qp.onDidAccept(async () => {
const qpDisposables = new DisposableStore();
qpDisposables.add(qp);
qpDisposables.add(qp.onDidAccept(async () => {
const sel = qp.selectedItems[0];
if (sel) {
const instance = all.get(Number(sel.id));
@@ -424,8 +427,11 @@ registerAction2(class ShowChatTerminalsAction extends Action2 {
} else {
qp.hide();
}
});
qp.onDidHide(() => qp.dispose());
}));
qpDisposables.add(qp.onDidHide(() => {
qpDisposables.dispose();
qp.dispose();
}));
qp.show();
}
});
@@ -519,4 +525,3 @@ CommandsRegistry.registerCommand(TerminalChatCommandId.DisableSessionAutoApprova
const terminalChatService = accessor.get(ITerminalChatService);
terminalChatService.setChatSessionAutoApproval(chatSessionId, false);
});
@@ -5,6 +5,7 @@
import { CancellationToken } from '../../../../../../base/common/cancellation.js';
import { Codicon } from '../../../../../../base/common/codicons.js';
import { URI } from '../../../../../../base/common/uri.js';
import { localize } from '../../../../../../nls.js';
import { CountTokensCallback, IPreparedToolInvocation, IToolData, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolInvocationPresentation, ToolProgress } from '../../../../chat/common/languageModelToolsService.js';
import { RunInTerminalTool } from './runInTerminalTool.js';
@@ -58,6 +59,18 @@ export const ConfirmTerminalCommandToolData: IToolData = {
export class ConfirmTerminalCommandTool extends RunInTerminalTool {
override async prepareToolInvocation(context: IToolInvocationPreparationContext, token: CancellationToken): Promise<IPreparedToolInvocation | undefined> {
// Safe-guard: If session is the chat provider specific id
// then convert it to the session id understood by chat service
try {
const sessionUri = context.chatSessionId ? URI.parse(context.chatSessionId) : undefined;
const sessionId = sessionUri ? this._chatService.getSession(sessionUri)?.sessionId : undefined;
if (sessionId) {
context.chatSessionId = sessionId;
}
}
catch {
// Ignore parse errors or session lookup failures; fallback to using the original chatSessionId.
}
const preparedInvocation = await super.prepareToolInvocation(context, token);
if (preparedInvocation) {
preparedInvocation.presentation = ToolInvocationPresentation.HiddenAfterComplete;
@@ -282,7 +282,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
}
constructor(
@IChatService private readonly _chatService: IChatService,
@IChatService protected readonly _chatService: IChatService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@IHistoryService private readonly _historyService: IHistoryService,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@@ -306,7 +306,9 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest
const quickSuggestionsConfig = this._configurationService.getValue<ITerminalSuggestConfiguration>(terminalSuggestConfigSection).quickSuggestions;
const allowFallbackCompletions = explicitlyInvoked || quickSuggestionsConfig.unknown === 'on';
this._logService.trace('SuggestAddon#_handleCompletionProviders provideCompletions');
const providedCompletions = await this._terminalCompletionService.provideCompletions(this._currentPromptInputState.value, this._currentPromptInputState.cursorIndex, allowFallbackCompletions, this.shellType, this._capabilities, token, false, doNotRequestExtensionCompletions, explicitlyInvoked);
// Trim ghost text from the prompt value when requesting completions
const promptValue = this._mostRecentPromptInputState?.ghostTextIndex !== undefined ? this._currentPromptInputState.value.substring(0, this._mostRecentPromptInputState?.ghostTextIndex) : this._currentPromptInputState.value;
const providedCompletions = await this._terminalCompletionService.provideCompletions(promptValue, this._currentPromptInputState.cursorIndex, allowFallbackCompletions, this.shellType, this._capabilities, token, false, doNotRequestExtensionCompletions, explicitlyInvoked);
this._logService.trace('SuggestAddon#_handleCompletionProviders provideCompletions done');
if (token.isCancellationRequested) {
@@ -23,6 +23,7 @@ import { isString } from '../../../../base/common/types.js';
import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js';
import { isWeb } from '../../../../base/common/platform.js';
import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js';
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
export const DEFAULT_ACCOUNT_SIGN_IN_COMMAND = 'workbench.actions.accounts.signIn';
@@ -112,6 +113,7 @@ export class DefaultAccountManagementContribution extends Disposable implements
@IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService,
@IConfigurationService private readonly configurationService: IConfigurationService,
@IAuthenticationService private readonly authenticationService: IAuthenticationService,
@ITelemetryService private readonly telemetryService: ITelemetryService,
@IExtensionService private readonly extensionService: IExtensionService,
@IProductService private readonly productService: IProductService,
@IRequestService private readonly requestService: IRequestService,
@@ -155,6 +157,18 @@ export class DefaultAccountManagementContribution extends Disposable implements
this.registerSignInAction(defaultAccountProviderId, this.productService.defaultAccount.authenticationProvider.scopes[0]);
this.setDefaultAccount(await this.getDefaultAccountFromAuthenticatedSessions(defaultAccountProviderId, this.productService.defaultAccount.authenticationProvider.scopes));
type DefaultAccountStatusTelemetry = {
status: string;
initial: boolean;
};
type DefaultAccountStatusTelemetryClassification = {
owner: 'sandy081';
comment: 'Log default account availability status';
status: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Indicates whether default account is available or not.' };
initial: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Indicates whether this is the initial status report.' };
};
this.telemetryService.publicLog2<DefaultAccountStatusTelemetry, DefaultAccountStatusTelemetryClassification>('defaultaccount:status', { status: this.defaultAccount ? 'available' : 'unavailable', initial: true });
this._register(this.authenticationService.onDidChangeSessions(async e => {
if (e.providerId !== this.getDefaultAccountProviderId()) {
return;
@@ -162,9 +176,11 @@ export class DefaultAccountManagementContribution extends Disposable implements
if (this.defaultAccount && e.event.removed?.some(session => session.id === this.defaultAccount?.sessionId)) {
this.setDefaultAccount(null);
return;
} else {
this.setDefaultAccount(await this.getDefaultAccountFromAuthenticatedSessions(defaultAccountProviderId, this.productService.defaultAccount!.authenticationProvider.scopes));
}
this.setDefaultAccount(await this.getDefaultAccountFromAuthenticatedSessions(defaultAccountProviderId, this.productService.defaultAccount!.authenticationProvider.scopes));
this.telemetryService.publicLog2<DefaultAccountStatusTelemetry, DefaultAccountStatusTelemetryClassification>('defaultaccount:status', { status: this.defaultAccount ? 'available' : 'unavailable', initial: false });
}));
this.logService.debug('[DefaultAccount] Initialization complete');
@@ -437,6 +437,12 @@ const apiMenus: IAPIMenu[] = [
description: localize('menus.mergeEditorResult', "The result toolbar of the merge editor"),
proposed: 'contribMergeEditorMenus'
},
{
key: 'multiDiffEditor/content',
id: MenuId.MultiDiffEditorContent,
description: localize('menus.multiDiffEditorContent', "A prominent button overlaying the multi diff editor"),
proposed: 'contribEditorContentMenu'
},
{
key: 'multiDiffEditor/resource/title',
id: MenuId.MultiDiffEditorFileToolbar,
@@ -467,6 +473,12 @@ const apiMenus: IAPIMenu[] = [
supportsSubmenus: false,
proposed: 'chatParticipantPrivate'
},
{
key: 'chat/input/editing/sessionToolbar',
id: MenuId.ChatEditingSessionChangesToolbar,
description: localize('menus.chatEditingSessionChangesToolbar', "The Chat Editing widget toolbar menu for session changes."),
proposed: 'chatSessionsProvider'
},
{
// TODO: rename this to something like: `chatSessions/item/inline`
key: 'chat/chatSessions',
+26 -2
View File
@@ -122,7 +122,7 @@ declare module 'vscode' {
/**
* Statistics about the chat session.
*/
statistics?: {
changes?: readonly ChatSessionChangedFile[] | {
/**
* Number of files edited during the session.
*/
@@ -140,6 +140,30 @@ declare module 'vscode' {
};
}
export class ChatSessionChangedFile {
/**
* URI of the file.
*/
modifiedUri: Uri;
/**
* File opened when the user takes the 'compare' action.
*/
originalUri?: Uri;
/**
* Number of insertions made during the session.
*/
insertions: number;
/**
* Number of deletions made during the session.
*/
deletions: number;
constructor(modifiedUri: Uri, insertions: number, deletions: number, originalUri?: Uri);
}
export interface ChatSession {
/**
* The full history of the session
@@ -199,7 +223,7 @@ declare module 'vscode' {
/**
* The new value assigned to the option. When `undefined`, the option is cleared.
*/
readonly value: string;
readonly value: string | ChatSessionProviderOptionItem;
}>;
}