mirror of
https://github.com/microsoft/vscode.git
synced 2026-05-08 17:19:48 +01:00
Merge branch 'main' into copilot/fix-suggest-widget-placement
This commit is contained in:
@@ -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,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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "code-oss-dev",
|
||||
"version": "1.107.0",
|
||||
"distro": "f7daaf68414ef6e47bec698f8babc297f0d90f0d",
|
||||
"version": "1.108.0",
|
||||
"distro": "f7ac66cb4d31a00eed97a9e72bc381bed8191387",
|
||||
"author": {
|
||||
"name": "Microsoft Corporation"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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> {
|
||||
|
||||
+10
-2
@@ -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,
|
||||
|
||||
+4
-2
@@ -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',
|
||||
|
||||
+12
-6
@@ -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,
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+93
-21
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+11
-13
@@ -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,
|
||||
|
||||
+80
-19
@@ -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;
|
||||
|
||||
+3
@@ -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;
|
||||
|
||||
+2
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
+13
@@ -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;
|
||||
|
||||
+1
-1
@@ -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
@@ -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;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user