diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index dea532739cb50..0f2e02380f81b 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -899,6 +899,7 @@ "--vscode-window-inactiveBorder" ], "others": [ + "--editor-font-size", "--background-dark", "--background-light", "--chat-editing-last-edit-shift", diff --git a/eslint.config.js b/eslint.config.js index b245f9466ac42..37fb7fe63bf5b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -899,7 +899,6 @@ export default tseslint.config( ], 'verbs': [ 'accept', - 'archive', 'change', 'close', 'collapse', diff --git a/package-lock.json b/package-lock.json index cb95a5a4cbee5..fc9b6b0f7a6ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,7 +53,7 @@ "v8-inspect-profiler": "^0.1.1", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", - "vscode-textmate": "^9.3.0", + "vscode-textmate": "^9.3.1", "yauzl": "^3.0.0", "yazl": "^2.4.3" }, @@ -17696,9 +17696,9 @@ } }, "node_modules/vscode-textmate": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.3.0.tgz", - "integrity": "sha512-zHiZZOdb9xqj5/X1C4a29sbgT2HngdWxPLSl3PyHRQF+5visI4uNM020OHiLJjsMxUssyk/pGVAg/9LCIobrVg==", + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.3.1.tgz", + "integrity": "sha512-U19nFkCraZF9/bkQKQYsb9mRqM9NwpToQQFl40nGiioZTH9gRtdtCHwp48cubayVfreX3ivnoxgxQgNwrTVmQg==", "license": "MIT" }, "node_modules/vscode-uri": { diff --git a/package.json b/package.json index 8e94496dee99c..e5ad7191f8756 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,7 @@ "v8-inspect-profiler": "^0.1.1", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", - "vscode-textmate": "^9.3.0", + "vscode-textmate": "^9.3.1", "yauzl": "^3.0.0", "yazl": "^2.4.3" }, diff --git a/remote/package-lock.json b/remote/package-lock.json index 80bae8718596d..30c5541fd6098 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -42,7 +42,7 @@ "tas-client": "0.3.1", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", - "vscode-textmate": "^9.3.0", + "vscode-textmate": "^9.3.1", "yauzl": "^3.0.0", "yazl": "^2.4.3" } @@ -1400,9 +1400,9 @@ } }, "node_modules/vscode-textmate": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.3.0.tgz", - "integrity": "sha512-zHiZZOdb9xqj5/X1C4a29sbgT2HngdWxPLSl3PyHRQF+5visI4uNM020OHiLJjsMxUssyk/pGVAg/9LCIobrVg==", + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.3.1.tgz", + "integrity": "sha512-U19nFkCraZF9/bkQKQYsb9mRqM9NwpToQQFl40nGiioZTH9gRtdtCHwp48cubayVfreX3ivnoxgxQgNwrTVmQg==", "license": "MIT" }, "node_modules/wrappy": { diff --git a/remote/package.json b/remote/package.json index 479adcd5410f4..d2eab8bf24a21 100644 --- a/remote/package.json +++ b/remote/package.json @@ -37,7 +37,7 @@ "tas-client": "0.3.1", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", - "vscode-textmate": "^9.3.0", + "vscode-textmate": "^9.3.1", "yauzl": "^3.0.0", "yazl": "^2.4.3" }, diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 6fef77cf22c00..fcdd633aa25d9 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -26,7 +26,7 @@ "katex": "^0.16.22", "tas-client": "0.3.1", "vscode-oniguruma": "1.7.0", - "vscode-textmate": "^9.3.0" + "vscode-textmate": "^9.3.1" } }, "node_modules/@microsoft/1ds-core-js": { @@ -266,9 +266,9 @@ "integrity": "sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==" }, "node_modules/vscode-textmate": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.3.0.tgz", - "integrity": "sha512-zHiZZOdb9xqj5/X1C4a29sbgT2HngdWxPLSl3PyHRQF+5visI4uNM020OHiLJjsMxUssyk/pGVAg/9LCIobrVg==", + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.3.1.tgz", + "integrity": "sha512-U19nFkCraZF9/bkQKQYsb9mRqM9NwpToQQFl40nGiioZTH9gRtdtCHwp48cubayVfreX3ivnoxgxQgNwrTVmQg==", "license": "MIT" }, "node_modules/yallist": { diff --git a/remote/web/package.json b/remote/web/package.json index a90d2e5b957a0..20b48882695a6 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -21,6 +21,6 @@ "katex": "^0.16.22", "tas-client": "0.3.1", "vscode-oniguruma": "1.7.0", - "vscode-textmate": "^9.3.0" + "vscode-textmate": "^9.3.1" } } diff --git a/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts b/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts index 389aac8f1133b..753bd958113fd 100644 --- a/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts +++ b/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts @@ -286,6 +286,7 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE this._configuration = this._register(this._createConfiguration(codeEditorWidgetOptions.isSimpleWidget || false, codeEditorWidgetOptions.contextMenuId ?? (codeEditorWidgetOptions.isSimpleWidget ? MenuId.SimpleEditorContext : MenuId.EditorContext), options, accessibilityService)); + this._domElement.style?.setProperty('--editor-font-size', this._configuration.options.get(EditorOption.fontSize) + 'px'); this._register(this._configuration.onDidChange((e) => { this._onDidChangeConfiguration.fire(e); @@ -294,6 +295,9 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE const layoutInfo = options.get(EditorOption.layoutInfo); this._onDidLayoutChange.fire(layoutInfo); } + if (e.hasChanged(EditorOption.fontSize)) { + this._domElement.style.setProperty('--editor-font-size', options.get(EditorOption.fontSize) + 'px'); + } })); this._contextKeyService = this._register(contextKeyService.createScoped(this._domElement)); diff --git a/src/vs/editor/common/languages.ts b/src/vs/editor/common/languages.ts index 8371086612760..95af648f724c4 100644 --- a/src/vs/editor/common/languages.ts +++ b/src/vs/editor/common/languages.ts @@ -72,8 +72,8 @@ export interface IFontToken { readonly startIndex: number; readonly endIndex: number; readonly fontFamily: string | null; - readonly fontSize: string | null; - readonly lineHeight: number | null; + readonly fontSizeMultiplier: number | null; + readonly lineHeightMultiplier: number | null; } /** diff --git a/src/vs/editor/common/languages/supports/tokenization.ts b/src/vs/editor/common/languages/supports/tokenization.ts index 076b443f58f4a..0545b34945d65 100644 --- a/src/vs/editor/common/languages/supports/tokenization.ts +++ b/src/vs/editor/common/languages/supports/tokenization.ts @@ -429,10 +429,10 @@ export function generateTokensCSSForFontMap(fontMap: readonly IFontTokenOptions[ const fonts = new Set(); for (let i = 1, len = fontMap.length; i < len; i++) { const font = fontMap[i]; - if (!font.fontFamily && !font.fontSize) { + if (!font.fontFamily && !font.fontSizeMultiplier) { continue; } - const className = classNameForFontTokenDecorations(font.fontFamily ?? '', font.fontSize ?? ''); + const className = classNameForFontTokenDecorations(font.fontFamily ?? '', font.fontSizeMultiplier ?? 0); if (fonts.has(className)) { continue; } @@ -441,8 +441,8 @@ export function generateTokensCSSForFontMap(fontMap: readonly IFontTokenOptions[ if (font.fontFamily) { rule += `font-family: ${font.fontFamily};`; } - if (font.fontSize) { - rule += `font-size: ${font.fontSize};`; + if (font.fontSizeMultiplier) { + rule += `font-size: calc(var(--editor-font-size)*${font.fontSizeMultiplier});`; } rule += `}`; rules.push(rule); @@ -450,6 +450,19 @@ export function generateTokensCSSForFontMap(fontMap: readonly IFontTokenOptions[ return rules.join('\n'); } -export function classNameForFontTokenDecorations(fontFamily: string, fontSize: string): string { - return `font-decoration-${fontFamily.toLowerCase()}-${fontSize.toLowerCase()}`; +export function classNameForFontTokenDecorations(fontFamily: string, fontSize: number): string { + const safeFontFamily = sanitizeFontFamilyForClassName(fontFamily); + return cleanClassName(`font-decoration-${safeFontFamily}-${fontSize}`); +} + +function sanitizeFontFamilyForClassName(fontFamily: string): string { + const normalized = fontFamily.toLowerCase().trim(); + if (!normalized) { + return 'default'; + } + return cleanClassName(normalized); +} + +function cleanClassName(className: string): string { + return className.replace(/[^a-z0-9_-]/gi, '-'); } diff --git a/src/vs/editor/common/model/tokens/tokenizationFontDecorationsProvider.ts b/src/vs/editor/common/model/tokens/tokenizationFontDecorationsProvider.ts index ccf4b297be323..0ab0c461ed0ae 100644 --- a/src/vs/editor/common/model/tokens/tokenizationFontDecorationsProvider.ts +++ b/src/vs/editor/common/model/tokens/tokenizationFontDecorationsProvider.ts @@ -75,8 +75,8 @@ export class TokenizationFontDecorationProvider extends Disposable implements De }; TokenizationFontDecorationProvider.DECORATION_COUNT++; - if (annotation.annotation.lineHeight) { - affectedLineHeights.add(new LineHeightChangingDecoration(0, decorationId, lineNumber, annotation.annotation.lineHeight)); + if (annotation.annotation.lineHeightMultiplier) { + affectedLineHeights.add(new LineHeightChangingDecoration(0, decorationId, lineNumber, annotation.annotation.lineHeightMultiplier)); } affectedLineFonts.add(new LineFontChangingDecoration(0, decorationId, lineNumber)); @@ -135,8 +135,8 @@ export class TokenizationFontDecorationProvider extends Disposable implements De const annotationEndPosition = this.textModel.getPositionAt(annotation.range.endExclusive); const range = Range.fromPositions(annotationStartPosition, annotationEndPosition); const anno = annotation.annotation; - const className = classNameForFontTokenDecorations(anno.fontToken.fontFamily ?? '', anno.fontToken.fontSize ?? ''); - const affectsFont = !!(anno.fontToken.fontFamily || anno.fontToken.fontSize); + const className = classNameForFontTokenDecorations(anno.fontToken.fontFamily ?? '', anno.fontToken.fontSizeMultiplier ?? 0); + const affectsFont = !!(anno.fontToken.fontFamily || anno.fontToken.fontSizeMultiplier); const id = anno.decorationId; decorations.push({ id: id, diff --git a/src/vs/editor/common/textModelEvents.ts b/src/vs/editor/common/textModelEvents.ts index cc142ebb8c5eb..b25c00aae8a70 100644 --- a/src/vs/editor/common/textModelEvents.ts +++ b/src/vs/editor/common/textModelEvents.ts @@ -162,11 +162,11 @@ export interface IFontTokenOption { /** * Font size of the token. */ - readonly fontSize?: string; + readonly fontSizeMultiplier?: number; /** * Line height of the token. */ - readonly lineHeight?: number; + readonly lineHeightMultiplier?: number; } /** @@ -189,8 +189,8 @@ export function serializeFontTokenOptions(): (options: IFontTokenOption) => IFon return (annotation: IFontTokenOption) => { return { fontFamily: annotation.fontFamily ?? '', - fontSize: annotation.fontSize ?? '', - lineHeight: annotation.lineHeight ?? 0 + fontSizeMultiplier: annotation.fontSizeMultiplier ?? 0, + lineHeightMultiplier: annotation.lineHeightMultiplier ?? 0 }; }; } @@ -202,8 +202,8 @@ export function deserializeFontTokenOptions(): (options: IFontTokenOption) => IF return (annotation: IFontTokenOption) => { return { fontFamily: annotation.fontFamily ? String(annotation.fontFamily) : undefined, - fontSize: annotation.fontSize ? String(annotation.fontSize) : undefined, - lineHeight: annotation.lineHeight ? Number(annotation.lineHeight) : undefined + fontSizeMultiplier: annotation.fontSizeMultiplier ? Number(annotation.fontSizeMultiplier) : undefined, + lineHeightMultiplier: annotation.lineHeightMultiplier ? Number(annotation.lineHeightMultiplier) : undefined }; }; } @@ -348,13 +348,13 @@ export class ModelLineHeightChanged { /** * The line height on the line. */ - public readonly lineHeight: number | null; + public readonly lineHeightMultiplier: number | null; - constructor(ownerId: number, decorationId: string, lineNumber: number, lineHeight: number | null) { + constructor(ownerId: number, decorationId: string, lineNumber: number, lineHeightMultiplier: number | null) { this.ownerId = ownerId; this.decorationId = decorationId; this.lineNumber = lineNumber; - this.lineHeight = lineHeight; + this.lineHeightMultiplier = lineHeightMultiplier; } } diff --git a/src/vs/editor/common/viewModel/viewModelImpl.ts b/src/vs/editor/common/viewModel/viewModelImpl.ts index 3fab2ddee2e58..101b46af347c2 100644 --- a/src/vs/editor/common/viewModel/viewModelImpl.ts +++ b/src/vs/editor/common/viewModel/viewModelImpl.ts @@ -451,10 +451,10 @@ export class ViewModel extends Disposable implements IViewModel { this.viewLayout.changeSpecialLineHeights((accessor: ILineHeightChangeAccessor) => { for (const change of filteredChanges) { - const { decorationId, lineNumber, lineHeight } = change; + const { decorationId, lineNumber, lineHeightMultiplier } = change; const viewRange = this.coordinatesConverter.convertModelRangeToViewRange(new Range(lineNumber, 1, lineNumber, this.model.getLineMaxColumn(lineNumber))); - if (lineHeight !== null) { - accessor.insertOrChangeCustomLineHeight(decorationId, viewRange.startLineNumber, viewRange.endLineNumber, lineHeight); + if (lineHeightMultiplier !== null) { + accessor.insertOrChangeCustomLineHeight(decorationId, viewRange.startLineNumber, viewRange.endLineNumber, lineHeightMultiplier * this._configuration.options.get(EditorOption.lineHeight)); } else { accessor.removeCustomLineHeight(decorationId); } diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index b82fea314178e..530b0e30433d9 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -291,6 +291,7 @@ export class MenuId { static readonly AgentSessionsToolbar = new MenuId('AgentSessionsToolbar'); static readonly AgentSessionItemToolbar = new MenuId('AgentSessionItemToolbar'); static readonly AgentSessionSectionToolbar = new MenuId('AgentSessionSectionToolbar'); + static readonly AgentsControlMenu = new MenuId('AgentsControlMenu'); static readonly ChatViewSessionTitleNavigationToolbar = new MenuId('ChatViewSessionTitleNavigationToolbar'); static readonly ChatViewSessionTitleToolbar = new MenuId('ChatViewSessionTitleToolbar'); diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 069ec076c4256..2a0fdff9f2430 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -69,7 +69,7 @@ const _allApiProposals = { }, chatSessionsProvider: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts', - version: 4 + version: 3 }, chatStatusItem: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatStatusItem.d.ts', diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index 75a302bd0e946..695a42bb817de 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -230,6 +230,15 @@ export interface ICommonNativeHostService { // Registry (Windows only) windowsGetStringRegKey(hive: 'HKEY_CURRENT_USER' | 'HKEY_LOCAL_MACHINE' | 'HKEY_CLASSES_ROOT' | 'HKEY_USERS' | 'HKEY_CURRENT_CONFIG', path: string, name: string): Promise; + + // Zip + /** + * Creates a zip file at the specified path containing the provided files. + * + * @param zipPath The URI where the zip file should be created. + * @param files An array of file entries to include in the zip, each with a relative path and string contents. + */ + createZipFile(zipPath: URI, files: { path: string; contents: string }[]): Promise; } export const INativeHostService = createDecorator('nativeHostService'); diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index 2c3b710261b89..ee61af0531018 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -43,6 +43,7 @@ import { IV8Profile } from '../../profiling/common/profiling.js'; import { IAuxiliaryWindowsMainService } from '../../auxiliaryWindow/electron-main/auxiliaryWindows.js'; import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js'; import { CancellationError } from '../../../base/common/errors.js'; +import { zip } from '../../../base/node/zip.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IProxyAuthService } from './auth.js'; import { AuthInfo, Credentials, IRequestService } from '../../request/common/request.js'; @@ -1168,6 +1169,14 @@ export class NativeHostMainService extends Disposable implements INativeHostMain //#endregion + //#region Zip + + async createZipFile(windowId: number | undefined, zipPath: URI, files: { path: string; contents: string }[]): Promise { + await zip(zipPath.fsPath, files); + } + + //#endregion + private windowById(windowId: number | undefined, fallbackCodeWindowId?: number): ICodeWindow | IAuxiliaryWindow | undefined { return this.codeWindowById(windowId) ?? this.auxiliaryWindowById(windowId) ?? this.codeWindowById(fallbackCodeWindowId); } diff --git a/src/vs/platform/theme/common/themeService.ts b/src/vs/platform/theme/common/themeService.ts index 9a4657d9a7a3e..33fbf67cde3c0 100644 --- a/src/vs/platform/theme/common/themeService.ts +++ b/src/vs/platform/theme/common/themeService.ts @@ -83,8 +83,8 @@ export interface IColorTheme { export class IFontTokenOptions { fontFamily?: string; - fontSize?: string; - lineHeight?: number; + fontSizeMultiplier?: number; + lineHeightMultiplier?: number; } export interface IFileIconTheme { diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 38de78caf4a25..6a18a39b05ff6 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -382,6 +382,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat )); } + $onDidChangeChatSessionItems(handle: number): void { this._itemProvidersRegistrations.get(handle)?.onDidChangeItems.fire(); } @@ -490,7 +491,6 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat resource: uri, iconPath: session.iconPath, tooltip: session.tooltip ? this._reviveTooltip(session.tooltip) : undefined, - archived: session.archived, } satisfies IChatSessionItem; })); } catch (error) { diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 02eeb78c9373d..a77a0079ee02e 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1530,10 +1530,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatSessionsProvider'); return extHostChatSessions.registerChatSessionItemProvider(extension, chatSessionType, provider); }, - createChatSessionItemController: (chatSessionType: string, refreshHandler: () => Thenable) => { - checkProposedApiEnabled(extension, 'chatSessionsProvider'); - return extHostChatSessions.createChatSessionItemController(extension, chatSessionType, refreshHandler); - }, registerChatSessionContentProvider(scheme: string, provider: vscode.ChatSessionContentProvider, chatParticipant: vscode.ChatParticipant, capabilities?: vscode.ChatSessionCapabilities) { checkProposedApiEnabled(extension, 'chatSessionsProvider'); return extHostChatSessions.registerChatSessionContentProvider(extension, scheme, chatParticipant, provider, capabilities); diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index c4d34921e4521..bc7366256c194 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -2,14 +2,12 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable local/code-no-native-private */ import type * as vscode from 'vscode'; import { coalesce } from '../../../base/common/arrays.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { CancellationError } from '../../../base/common/errors.js'; -import { Emitter } from '../../../base/common/event.js'; -import { Disposable, DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../base/common/map.js'; import { MarshalledId } from '../../../base/common/marshallingIds.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; @@ -31,177 +29,6 @@ import { basename } from '../../../base/common/resources.js'; import { Diagnostic } from './extHostTypeConverters.js'; import { SymbolKind, SymbolKinds } from '../../../editor/common/languages.js'; -type ChatSessionTiming = vscode.ChatSessionItem['timing']; - -// #region Chat Session Item Controller - -class ChatSessionItemImpl implements vscode.ChatSessionItem { - #label: string; - #iconPath?: vscode.IconPath; - #description?: string | vscode.MarkdownString; - #badge?: string | vscode.MarkdownString; - #status?: vscode.ChatSessionStatus; - #archived?: boolean; - #tooltip?: string | vscode.MarkdownString; - #timing?: ChatSessionTiming; - #changes?: readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number }; - #onChanged: () => void; - - readonly resource: vscode.Uri; - - constructor(resource: vscode.Uri, label: string, onChanged: () => void) { - this.resource = resource; - this.#label = label; - this.#onChanged = onChanged; - } - - get label(): string { - return this.#label; - } - - set label(value: string) { - if (this.#label !== value) { - this.#label = value; - this.#onChanged(); - } - } - - get iconPath(): vscode.IconPath | undefined { - return this.#iconPath; - } - - set iconPath(value: vscode.IconPath | undefined) { - if (this.#iconPath !== value) { - this.#iconPath = value; - this.#onChanged(); - } - } - - get description(): string | vscode.MarkdownString | undefined { - return this.#description; - } - - set description(value: string | vscode.MarkdownString | undefined) { - if (this.#description !== value) { - this.#description = value; - this.#onChanged(); - } - } - - get badge(): string | vscode.MarkdownString | undefined { - return this.#badge; - } - - set badge(value: string | vscode.MarkdownString | undefined) { - if (this.#badge !== value) { - this.#badge = value; - this.#onChanged(); - } - } - - get status(): vscode.ChatSessionStatus | undefined { - return this.#status; - } - - set status(value: vscode.ChatSessionStatus | undefined) { - if (this.#status !== value) { - this.#status = value; - this.#onChanged(); - } - } - - get archived(): boolean | undefined { - return this.#archived; - } - - set archived(value: boolean | undefined) { - if (this.#archived !== value) { - this.#archived = value; - this.#onChanged(); - } - } - - get tooltip(): string | vscode.MarkdownString | undefined { - return this.#tooltip; - } - - set tooltip(value: string | vscode.MarkdownString | undefined) { - if (this.#tooltip !== value) { - this.#tooltip = value; - this.#onChanged(); - } - } - - get timing(): ChatSessionTiming | undefined { - return this.#timing; - } - - set timing(value: ChatSessionTiming | undefined) { - if (this.#timing !== value) { - this.#timing = value; - this.#onChanged(); - } - } - - get changes(): readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number } | undefined { - return this.#changes; - } - - set changes(value: readonly vscode.ChatSessionChangedFile[] | { files: number; insertions: number; deletions: number } | undefined) { - if (this.#changes !== value) { - this.#changes = value; - this.#onChanged(); - } - } -} - -class ChatSessionItemCollectionImpl implements vscode.ChatSessionItemCollection { - readonly #items = new ResourceMap(); - #onItemsChanged: () => void; - - constructor(onItemsChanged: () => void) { - this.#onItemsChanged = onItemsChanged; - } - - get size(): number { - return this.#items.size; - } - - replace(items: readonly vscode.ChatSessionItem[]): void { - this.#items.clear(); - for (const item of items) { - this.#items.set(item.resource, item); - } - this.#onItemsChanged(); - } - - forEach(callback: (item: vscode.ChatSessionItem, collection: vscode.ChatSessionItemCollection) => unknown, thisArg?: any): void { - for (const [_, item] of this.#items) { - callback.call(thisArg, item, this); - } - } - - add(item: vscode.ChatSessionItem): void { - this.#items.set(item.resource, item); - this.#onItemsChanged(); - } - - delete(resource: vscode.Uri): void { - this.#items.delete(resource); - this.#onItemsChanged(); - } - - get(resource: vscode.Uri): vscode.ChatSessionItem | undefined { - return this.#items.get(resource); - } - - [Symbol.iterator](): Iterator { - return this.#items.entries(); - } -} - -// #endregion - class ExtHostChatSession { private _stream: ChatAgentResponseStream; @@ -235,20 +62,13 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio readonly extension: IExtensionDescription; readonly disposable: DisposableStore; }>(); - private readonly _chatSessionItemControllers = new Map(); - private _nextChatSessionItemProviderHandle = 0; private readonly _chatSessionContentProviders = new Map(); - private _nextChatSessionItemControllerHandle = 0; + private _nextChatSessionItemProviderHandle = 0; private _nextChatSessionContentProviderHandle = 0; /** @@ -320,52 +140,6 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }; } - - createChatSessionItemController(extension: IExtensionDescription, id: string, refreshHandler: () => Thenable): vscode.ChatSessionItemController { - const controllerHandle = this._nextChatSessionItemControllerHandle++; - const disposables = new DisposableStore(); - - // TODO: Currently not hooked up - const onDidArchiveChatSessionItem = disposables.add(new Emitter()); - - const collection = new ChatSessionItemCollectionImpl(() => { - this._proxy.$onDidChangeChatSessionItems(controllerHandle); - }); - - let isDisposed = false; - - const controller: vscode.ChatSessionItemController = { - id, - refreshHandler, - items: collection, - onDidArchiveChatSessionItem: onDidArchiveChatSessionItem.event, - createChatSessionItem: (resource: vscode.Uri, label: string) => { - if (isDisposed) { - throw new Error('ChatSessionItemController has been disposed'); - } - - return new ChatSessionItemImpl(resource, label, () => { - // TODO: Optimize to only update the specific item - this._proxy.$onDidChangeChatSessionItems(controllerHandle); - }); - }, - dispose: () => { - isDisposed = true; - disposables.dispose(); - }, - }; - - this._chatSessionItemControllers.set(controllerHandle, { controller, extension, disposable: disposables, sessionType: id }); - this._proxy.$registerChatSessionItemProvider(controllerHandle, id); - - disposables.add(toDisposable(() => { - this._chatSessionItemControllers.delete(controllerHandle); - this._proxy.$unregisterChatSessionItemProvider(controllerHandle); - })); - - return controller; - } - registerChatSessionContentProvider(extension: IExtensionDescription, chatSessionScheme: string, chatParticipant: vscode.ChatParticipant, provider: vscode.ChatSessionContentProvider, capabilities?: vscode.ChatSessionCapabilities): vscode.Disposable { const handle = this._nextChatSessionContentProviderHandle++; const disposables = new DisposableStore(); @@ -410,25 +184,17 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } } - private convertChatSessionItem(sessionContent: vscode.ChatSessionItem): IChatSessionItem { - // Support both new (created, lastRequestStarted, lastRequestEnded) and old (startTime, endTime) timing properties - const timing = sessionContent.timing; - const created = timing?.created ?? timing?.startTime ?? 0; - const lastRequestStarted = timing?.lastRequestStarted ?? timing?.startTime; - const lastRequestEnded = timing?.lastRequestEnded ?? timing?.endTime; - + private convertChatSessionItem(sessionType: string, sessionContent: vscode.ChatSessionItem): IChatSessionItem { return { resource: sessionContent.resource, label: sessionContent.label, description: sessionContent.description ? typeConvert.MarkdownString.from(sessionContent.description) : undefined, badge: sessionContent.badge ? typeConvert.MarkdownString.from(sessionContent.badge) : undefined, status: this.convertChatSessionStatus(sessionContent.status), - archived: sessionContent.archived, tooltip: typeConvert.MarkdownString.fromStrict(sessionContent.tooltip), timing: { - created, - lastRequestStarted, - lastRequestEnded, + startTime: sessionContent.timing?.startTime ?? 0, + endTime: sessionContent.timing?.endTime }, changes: sessionContent.changes instanceof Array ? sessionContent.changes : @@ -441,35 +207,21 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } async $provideChatSessionItems(handle: number, token: vscode.CancellationToken): Promise { - let items: vscode.ChatSessionItem[]; - - const controller = this._chatSessionItemControllers.get(handle); - if (controller) { - // Call the refresh handler to populate items - await controller.controller.refreshHandler(); - if (token.isCancellationRequested) { - return []; - } - - items = Array.from(controller.controller.items, x => x[1]); - } else { - - const itemProvider = this._chatSessionItemProviders.get(handle); - if (!itemProvider) { - this._logService.error(`No provider registered for handle ${handle}`); - return []; - } + const entry = this._chatSessionItemProviders.get(handle); + if (!entry) { + this._logService.error(`No provider registered for handle ${handle}`); + return []; + } - items = await itemProvider.provider.provideChatSessionItems(token) ?? []; - if (token.isCancellationRequested) { - return []; - } + const sessions = await entry.provider.provideChatSessionItems(token); + if (!sessions) { + return []; } const response: IChatSessionItem[] = []; - for (const sessionContent of items) { + for (const sessionContent of sessions) { this._sessionItems.set(sessionContent.resource, sessionContent); - response.push(this.convertChatSessionItem(sessionContent)); + response.push(this.convertChatSessionItem(entry.sessionType, sessionContent)); } return response; } diff --git a/src/vs/workbench/browser/parts/titlebar/commandCenterControlRegistry.ts b/src/vs/workbench/browser/parts/titlebar/commandCenterControlRegistry.ts new file mode 100644 index 0000000000000..20eeafacdb0c9 --- /dev/null +++ b/src/vs/workbench/browser/parts/titlebar/commandCenterControlRegistry.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; + +/** + * Interface for a command center control that can be registered with the titlebar. + */ +export interface ICommandCenterControl extends IDisposable { + readonly element: HTMLElement; +} + +/** + * A registration for a custom command center control. + */ +export interface ICommandCenterControlRegistration { + /** + * The context key that must be truthy for this control to be shown. + * When this context key is true, this control replaces the default command center. + */ + readonly contextKey: string; + + /** + * Priority for when multiple controls match. Higher priority wins. + */ + readonly priority: number; + + /** + * Factory function to create the control. + */ + create(instantiationService: IInstantiationService): ICommandCenterControl; +} + +class CommandCenterControlRegistryImpl { + private readonly registrations: ICommandCenterControlRegistration[] = []; + + /** + * Register a custom command center control. + */ + register(registration: ICommandCenterControlRegistration): IDisposable { + this.registrations.push(registration); + // Sort by priority descending + this.registrations.sort((a, b) => b.priority - a.priority); + + return { + dispose: () => { + const index = this.registrations.indexOf(registration); + if (index >= 0) { + this.registrations.splice(index, 1); + } + } + }; + } + + /** + * Get all registered command center controls. + */ + getRegistrations(): readonly ICommandCenterControlRegistration[] { + return this.registrations; + } +} + +/** + * Registry for custom command center controls. + * Contrib modules can register controls here, and the titlebar will use them + * when their context key conditions are met. + */ +export const CommandCenterControlRegistry = new CommandCenterControlRegistryImpl(); diff --git a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts index 743f9e6ee8bba..831c4be238023 100644 --- a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts +++ b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts @@ -30,6 +30,7 @@ import { IContextKey, IContextKeyService } from '../../../../platform/contextkey import { IHostService } from '../../../services/host/browser/host.js'; import { WindowTitle } from './windowTitle.js'; import { CommandCenterControl } from './commandCenterControl.js'; +import { CommandCenterControlRegistry } from './commandCenterControlRegistry.js'; import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; import { WorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { ACCOUNTS_ACTIVITY_ID, GLOBAL_ACTIVITY_ID } from '../../../common/activity.js'; @@ -328,6 +329,14 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { this._register(this.hostService.onDidChangeActiveWindow(windowId => windowId === targetWindowId ? this.onFocus() : this.onBlur())); this._register(this.configurationService.onDidChangeConfiguration(e => this.onConfigurationChanged(e))); this._register(this.editorGroupsContainer.onDidChangeEditorPartOptions(e => this.onEditorPartConfigurationChange(e))); + + // Re-create title when any registered command center control's context key changes + this._register(this.contextKeyService.onDidChangeContext(e => { + const registeredContextKeys = new Set(CommandCenterControlRegistry.getRegistrations().map(r => r.contextKey)); + if (registeredContextKeys.size > 0 && e.affectsSome(registeredContextKeys)) { + this.createTitle(); + } + })); } private onBlur(): void { @@ -576,9 +585,24 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { // Menu Title else { - const commandCenter = this.instantiationService.createInstance(CommandCenterControl, this.windowTitle, this.hoverDelegate); - reset(this.title, commandCenter.element); - this.titleDisposables.add(commandCenter); + // Check if any registered command center control should be shown + let customControlShown = false; + for (const registration of CommandCenterControlRegistry.getRegistrations()) { + if (this.contextKeyService.getContextKeyValue(registration.contextKey)) { + const control = registration.create(this.instantiationService); + reset(this.title, control.element); + this.titleDisposables.add(control); + customControlShown = true; + break; + } + } + + if (!customControlShown) { + // Normal mode - show regular command center + const commandCenter = this.instantiationService.createInstance(CommandCenterControl, this.windowTitle, this.hoverDelegate); + reset(this.title, commandCenter.element); + this.titleDisposables.add(commandCenter); + } } } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 22ca202b3feea..df91b07dbd858 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -520,7 +520,7 @@ export function registerChatActions() { }, { id: MenuId.EditorTitle, group: 'navigation', - when: ContextKeyExpr.and(ActiveEditorContext.isEqualTo(ChatEditorInput.EditorID), ChatContextKeys.lockedToCodingAgent.negate()), + when: ActiveEditorContext.isEqualTo(ChatEditorInput.EditorID), order: 1 }], }); @@ -947,7 +947,8 @@ MenuRegistry.appendMenuItem(MenuId.CommandCenter, { ChatContextKeys.Setup.hidden.negate(), ChatContextKeys.Setup.disabled.negate() ), - ContextKeyExpr.has('config.chat.commandCenter.enabled') + ContextKeyExpr.has('config.chat.commandCenter.enabled'), + ContextKeyExpr.has(`config.${ChatConfiguration.AgentSessionProjectionEnabled}`).negate() // Hide when agent controls are shown ), order: 10001 // to the right of command center }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 810a13614cfc2..884cc180f4c83 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -347,9 +347,8 @@ class SwitchToNextModelAction extends Action2 { } } -export const ChatOpenModelPickerActionId = 'workbench.action.chat.openModelPicker'; -class OpenModelPickerAction extends Action2 { - static readonly ID = ChatOpenModelPickerActionId; +export class OpenModelPickerAction extends Action2 { + static readonly ID = 'workbench.action.chat.openModelPicker'; constructor() { super({ @@ -431,6 +430,41 @@ export class OpenModePickerAction extends Action2 { } } +export class OpenSessionTargetPickerAction extends Action2 { + static readonly ID = 'workbench.action.chat.openSessionTargetPicker'; + + constructor() { + super({ + id: OpenSessionTargetPickerAction.ID, + title: localize2('interactive.openSessionTargetPicker.label', "Open Session Target Picker"), + tooltip: localize('setSessionTarget', "Set Session Target"), + category: CHAT_CATEGORY, + f1: false, + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.hasCanDelegateProviders, ChatContextKeys.chatSessionIsEmpty), + menu: [ + { + id: MenuId.ChatInput, + order: 0, + when: ContextKeyExpr.and( + ChatContextKeys.enabled, + ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), + ChatContextKeys.inQuickChat.negate(), + ChatContextKeys.hasCanDelegateProviders), + group: 'navigation', + }, + ] + }); + } + + override async run(accessor: ServicesAccessor, ...args: unknown[]): Promise { + const widgetService = accessor.get(IChatWidgetService); + const widget = widgetService.lastFocusedWidget; + if (widget) { + widget.input.openSessionTargetPicker(); + } + } +} + export class ChatSessionPrimaryPickerAction extends Action2 { static readonly ID = 'workbench.action.chat.chatSessionPrimaryPicker'; constructor() { @@ -758,6 +792,7 @@ export function registerChatExecuteActions() { registerAction2(SwitchToNextModelAction); registerAction2(OpenModelPickerAction); registerAction2(OpenModePickerAction); + registerAction2(OpenSessionTargetPickerAction); registerAction2(ChatSessionPrimaryPickerAction); registerAction2(ChangeChatModelAction); registerAction2(CancelEdit); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts index 1476c1660a0a5..9daecba598b3f 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts @@ -5,6 +5,8 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; import { localize, localize2 } from '../../../../../nls.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; @@ -14,16 +16,21 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ActiveEditorContext } from '../../../../common/contextkeys.js'; +import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatEditingSession } from '../../common/editing/chatEditingService.js'; import { IChatService } from '../../common/chatService/chatService.js'; +import { localChatSessionType } from '../../common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; -import { ChatViewId, IChatWidgetService } from '../chat.js'; +import { getChatSessionType, LocalChatSessionUri } from '../../common/model/chatUri.js'; +import { ChatViewId, IChatWidgetService, isIChatViewViewContext } from '../chat.js'; import { EditingSessionAction, getEditingSessionContext } from '../chatEditing/chatEditingActions.js'; import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js'; +import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.js'; import { ACTION_ID_NEW_CHAT, ACTION_ID_NEW_EDIT_SESSION, CHAT_CATEGORY, handleCurrentEditingSession } from './chatActions.js'; import { clearChatEditor } from './chatClear.js'; import { AgentSessionsViewerOrientation } from '../agentSessions/agentSessions.js'; +import { IFocusViewService } from '../agentSessions/focusViewService.js'; export interface INewEditSessionActionContext { @@ -95,7 +102,7 @@ export function registerNewChatActions() { { id: MenuId.CompactWindowEditorTitle, group: 'navigation', - when: ContextKeyExpr.and(ActiveEditorContext.isEqualTo(ChatEditorInput.EditorID), ChatContextKeys.lockedToCodingAgent.negate()), + when: ActiveEditorContext.isEqualTo(ChatEditorInput.EditorID), order: 1 } ], @@ -114,6 +121,14 @@ export function registerNewChatActions() { async run(accessor: ServicesAccessor, ...args: unknown[]) { const accessibilityService = accessor.get(IAccessibilityService); + const focusViewService = accessor.get(IFocusViewService); + + // Exit focus view mode if active (back button behavior) + if (focusViewService.isActive) { + await focusViewService.exitFocusView(); + return; + } + const viewsService = accessor.get(IViewsService); const executeCommandContext = args[0] as INewEditSessionActionContext | undefined; @@ -132,7 +147,20 @@ export function registerNewChatActions() { } await editingSession?.stop(); - await widget.clear(); + + // Create a new session with the same type as the current session + if (isIChatViewViewContext(widget.viewContext)) { + // For the sidebar, we need to explicitly load a session with the same type + const currentResource = widget.viewModel?.model.sessionResource; + const sessionType = currentResource ? getChatSessionType(currentResource) : localChatSessionType; + const newResource = getResourceForNewChatSession(sessionType); + const view = await viewsService.openView(ChatViewId) as ChatViewPane; + await view.loadSession(newResource); + } else { + // For the editor, widget.clear() already preserves the session type via clearChatEditor + await widget.clear(); + } + widget.attachmentModel.clear(true); widget.input.relatedFiles?.clear(); widget.focusInput(); @@ -259,3 +287,20 @@ export function registerNewChatActions() { } }); } + +/** + * Creates a new session resource URI with the specified session type. + * For remote sessions, creates a URI with the session type as the scheme. + * For local sessions, creates a LocalChatSessionUri. + */ +function getResourceForNewChatSession(sessionType: string): URI { + const isRemoteSession = sessionType !== localChatSessionType; + if (isRemoteSession) { + return URI.from({ + scheme: sessionType, + path: `/untitled-${generateUuid()}`, + }); + } + + return LocalChatSessionUri.forSession(generateUuid()); +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts index 45142e50c1b76..8accd14a1796e 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts @@ -4,7 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../../base/common/codicons.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; import { localize, localize2 } from '../../../../../nls.js'; +import { mainWindow } from '../../../../../base/browser/window.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { registerSingleton, InstantiationType } from '../../../../../platform/instantiation/common/extensions.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; @@ -13,10 +15,17 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { AgentSessionsViewerOrientation, AgentSessionsViewerPosition } from './agentSessions.js'; import { IAgentSessionsService, AgentSessionsService } from './agentSessionsService.js'; import { LocalAgentsSessionsProvider } from './localAgentSessionsProvider.js'; -import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; -import { ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; +import { ISubmenuItem, MenuId, MenuRegistry, registerAction2, SubmenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { ArchiveAgentSessionAction, ArchiveAgentSessionSectionAction, UnarchiveAgentSessionSectionAction, UnarchiveAgentSessionAction, OpenAgentSessionInEditorGroupAction, OpenAgentSessionInNewEditorGroupAction, OpenAgentSessionInNewWindowAction, ShowAgentSessionsSidebar, HideAgentSessionsSidebar, ToggleAgentSessionsSidebar, RefreshAgentSessionsViewerAction, FindAgentSessionInViewerAction, MarkAgentSessionUnreadAction, MarkAgentSessionReadAction, FocusAgentSessionsAction, SetAgentSessionsOrientationStackedAction, SetAgentSessionsOrientationSideBySideAction, ToggleChatViewSessionsAction, PickAgentSessionAction, ArchiveAllAgentSessionsAction, RenameAgentSessionAction, DeleteAgentSessionAction, DeleteAllLocalSessionsAction } from './agentSessionsActions.js'; import { AgentSessionsQuickAccessProvider, AGENT_SESSIONS_QUICK_ACCESS_PREFIX } from './agentSessionsQuickAccess.js'; +import { IFocusViewService, FocusViewService } from './focusViewService.js'; +import { EnterFocusViewAction, ExitFocusViewAction, OpenInChatPanelAction, ToggleAgentsControl } from './focusViewActions.js'; +import { AgentsControlViewItem } from './agentsControl.js'; +import { IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ChatConfiguration } from '../../common/constants.js'; //#region Actions and Menus @@ -44,6 +53,12 @@ registerAction2(ToggleChatViewSessionsAction); registerAction2(SetAgentSessionsOrientationStackedAction); registerAction2(SetAgentSessionsOrientationSideBySideAction); +// Focus View +registerAction2(EnterFocusViewAction); +registerAction2(ExitFocusViewAction); +registerAction2(OpenInChatPanelAction); +registerAction2(ToggleAgentsControl); + // --- Agent Sessions Toolbar MenuRegistry.appendMenuItem(MenuId.AgentSessionsToolbar, { @@ -169,5 +184,65 @@ Registry.as(QuickAccessExtensions.Quickaccess).registerQui registerWorkbenchContribution2(LocalAgentsSessionsProvider.ID, LocalAgentsSessionsProvider, WorkbenchPhase.AfterRestored); registerSingleton(IAgentSessionsService, AgentSessionsService, InstantiationType.Delayed); +registerSingleton(IFocusViewService, FocusViewService, InstantiationType.Delayed); + +// Register Agents Control as a menu item in the command center (alongside the search box, not replacing it) +MenuRegistry.appendMenuItem(MenuId.CommandCenter, { + submenu: MenuId.AgentsControlMenu, + title: localize('agentsControl', "Agents"), + icon: Codicon.chatSparkle, + when: ContextKeyExpr.has(`config.${ChatConfiguration.AgentSessionProjectionEnabled}`), + order: 10002 // to the right of the chat button +}); + +// Register a placeholder action to the submenu so it appears (required for submenus) +MenuRegistry.appendMenuItem(MenuId.AgentsControlMenu, { + command: { + id: 'workbench.action.chat.toggle', + title: localize('openChat', "Open Chat"), + }, + when: ContextKeyExpr.has(`config.${ChatConfiguration.AgentSessionProjectionEnabled}`), +}); + +/** + * Provides custom rendering for the agents control in the command center. + * Uses IActionViewItemService to render a custom AgentsControlViewItem + * for the AgentsControlMenu submenu. + * Also adds a CSS class to the workbench when agents control is enabled. + */ +class AgentsControlRendering extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.agentsControl.rendering'; + + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + @IInstantiationService instantiationService: IInstantiationService, + @IConfigurationService configurationService: IConfigurationService + ) { + super(); + + this._register(actionViewItemService.register(MenuId.CommandCenter, MenuId.AgentsControlMenu, (action, options) => { + if (!(action instanceof SubmenuItemAction)) { + return undefined; + } + return instantiationService.createInstance(AgentsControlViewItem, action, options); + }, undefined)); + + // Add/remove CSS class on workbench based on setting + const updateClass = () => { + const enabled = configurationService.getValue(ChatConfiguration.AgentSessionProjectionEnabled) === true; + mainWindow.document.body.classList.toggle('agents-control-enabled', enabled); + }; + updateClass(); + this._register(configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ChatConfiguration.AgentSessionProjectionEnabled)) { + updateClass(); + } + })); + } +} + +// Register the workbench contribution that provides custom rendering for the agents control +registerWorkbenchContribution2(AgentsControlRendering.ID, AgentsControlRendering, WorkbenchPhase.AfterRestored); //#endregion diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index 54b47048b3cbd..0993aa2e8c8fb 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -9,6 +9,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { localChatSessionType } from '../../common/chatSessionsService.js'; import { foreground, listActiveSelectionForeground, registerColor, transparent } from '../../../../../platform/theme/common/colorRegistry.js'; +import { getChatSessionType } from '../../common/model/chatUri.js'; export enum AgentSessionProviders { Local = localChatSessionType, @@ -16,6 +17,18 @@ export enum AgentSessionProviders { Cloud = 'copilot-cloud-agent', } +export function getAgentSessionProvider(sessionResource: URI | string): AgentSessionProviders | undefined { + const type = URI.isUri(sessionResource) ? getChatSessionType(sessionResource) : sessionResource; + switch (type) { + case AgentSessionProviders.Local: + case AgentSessionProviders.Background: + case AgentSessionProviders.Cloud: + return type; + default: + return undefined; + } +} + export function getAgentSessionProviderName(provider: AgentSessionProviders): string { switch (provider) { case AgentSessionProviders.Local: diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index 73776e50163f9..b579321fec1d4 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -359,24 +359,19 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode ? { files: changes.files, insertions: changes.insertions, deletions: changes.deletions } : changes; - // Times: it is important to always provide timing information to track + // Times: it is important to always provide a start and end time to track // unread/read state for example. // If somehow the provider does not provide any, fallback to last known - let created = session.timing.created; - let lastRequestStarted = session.timing.lastRequestStarted; - let lastRequestEnded = session.timing.lastRequestEnded; - if (!created || !lastRequestEnded) { + let startTime = session.timing.startTime; + let endTime = session.timing.endTime; + if (!startTime || !endTime) { const existing = this._sessions.get(session.resource); - if (!created && existing?.timing.created) { - created = existing.timing.created; + if (!startTime && existing?.timing.startTime) { + startTime = existing.timing.startTime; } - if (!lastRequestEnded && existing?.timing.lastRequestEnded) { - lastRequestEnded = existing.timing.lastRequestEnded; - } - - if (!lastRequestStarted && existing?.timing.lastRequestStarted) { - lastRequestStarted = existing.timing.lastRequestStarted; + if (!endTime && existing?.timing.endTime) { + endTime = existing.timing.endTime; } } @@ -391,13 +386,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode tooltip: session.tooltip, status, archived: session.archived, - timing: { - created, - lastRequestStarted, - lastRequestEnded, - inProgressTime, - finishedOrFailedTime - }, + timing: { startTime, endTime, inProgressTime, finishedOrFailedTime }, changes: normalizedChanges, })); } @@ -465,7 +454,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode private isRead(session: IInternalAgentSessionData): boolean { const readDate = this.sessionStates.get(session.resource)?.read; - return (readDate ?? AgentSessionsModel.READ_STATE_INITIAL_DATE) >= (session.timing.lastRequestEnded ?? session.timing.lastRequestStarted ?? session.timing.created); + return (readDate ?? AgentSessionsModel.READ_STATE_INITIAL_DATE) >= (session.timing.endTime ?? session.timing.startTime); } private setRead(session: IInternalAgentSessionData, read: boolean): void { @@ -484,7 +473,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode //#region Sessions Cache -interface ISerializedAgentSession { +interface ISerializedAgentSession extends Omit { readonly providerType: string; readonly providerLabel: string; @@ -503,11 +492,7 @@ interface ISerializedAgentSession { readonly archived: boolean | undefined; readonly timing: { - readonly created: number; - readonly lastRequestStarted?: number; - readonly lastRequestEnded?: number; - // Old format for backward compatibility when reading - readonly startTime?: number; + readonly startTime: number; readonly endTime?: number; }; @@ -550,9 +535,8 @@ class AgentSessionsCache { archived: session.archived, timing: { - created: session.timing.created, - lastRequestStarted: session.timing.lastRequestStarted, - lastRequestEnded: session.timing.lastRequestEnded, + startTime: session.timing.startTime, + endTime: session.timing.endTime, }, changes: session.changes, @@ -569,7 +553,7 @@ class AgentSessionsCache { try { const cached = JSON.parse(sessionsCache) as ISerializedAgentSession[]; - return cached.map((session): IInternalAgentSessionData => ({ + return cached.map(session => ({ providerType: session.providerType, providerLabel: session.providerLabel, @@ -585,10 +569,8 @@ class AgentSessionsCache { archived: session.archived, timing: { - // Support loading both new and old cache formats - created: session.timing.created ?? session.timing.startTime ?? 0, - lastRequestStarted: session.timing.lastRequestStarted ?? session.timing.startTime, - lastRequestEnded: session.timing.lastRequestEnded ?? session.timing.endTime, + startTime: session.timing.startTime, + endTime: session.timing.endTime, }, changes: Array.isArray(session.changes) ? session.changes.map((change: IChatSessionFileChange) => ({ diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts index c895c8f8eaff7..75cd153a25999 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts @@ -11,8 +11,39 @@ import { ACTIVE_GROUP, SIDE_GROUP } from '../../../../services/editor/common/edi import { IEditorOptions } from '../../../../../platform/editor/common/editor.js'; import { IChatSessionsService } from '../../common/chatSessionsService.js'; import { Schemas } from '../../../../../base/common/network.js'; +import { IFocusViewService } from './focusViewService.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ChatConfiguration } from '../../common/constants.js'; export async function openSession(accessor: ServicesAccessor, session: IAgentSession, openOptions?: { sideBySide?: boolean; editorOptions?: IEditorOptions }): Promise { + const configurationService = accessor.get(IConfigurationService); + const focusViewService = accessor.get(IFocusViewService); + + session.setRead(true); // mark as read when opened + + // Local chat sessions (chat history) should always open in the chat widget + if (isLocalAgentSessionItem(session)) { + await openSessionInChatWidget(accessor, session, openOptions); + return; + } + + // Check if Agent Session Projection is enabled for agent sessions + const agentSessionProjectionEnabled = configurationService.getValue(ChatConfiguration.AgentSessionProjectionEnabled) === true; + + if (agentSessionProjectionEnabled) { + // Enter Agent Session Projection mode for the session + await focusViewService.enterFocusView(session); + } else { + // Fall back to opening in chat widget when Agent Session Projection is disabled + await openSessionInChatWidget(accessor, session, openOptions); + } +} + +/** + * Opens a session in the traditional chat widget (side panel or editor). + * Use this when you explicitly want to open in the chat widget rather than agent session projection mode. + */ +export async function openSessionInChatWidget(accessor: ServicesAccessor, session: IAgentSession, openOptions?: { sideBySide?: boolean; editorOptions?: IEditorOptions }): Promise { const chatSessionsService = accessor.get(IChatSessionsService); const chatWidgetService = accessor.get(IChatWidgetService); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts index ba5bfac455de8..cd91ba6fbdb70 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts @@ -44,7 +44,7 @@ export const deleteButton: IQuickInputButton = { export function getSessionDescription(session: IAgentSession): string { const descriptionText = typeof session.description === 'string' ? session.description : session.description ? renderAsPlaintext(session.description) : undefined; - const timeAgo = fromNow(session.timing.lastRequestEnded ?? session.timing.lastRequestStarted ?? session.timing.created); + const timeAgo = fromNow(session.timing.endTime || session.timing.startTime); const descriptionParts = [descriptionText, session.providerLabel, timeAgo].filter(part => !!part); return descriptionParts.join(' • '); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 17c8d9f3a5aae..f3d3e6e29cdcd 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -323,7 +323,7 @@ export class AgentSessionRenderer implements ICompressibleTreeRenderer= startOfToday) { todaySessions.push(session); } else if (sessionTime >= startOfYesterday) { @@ -827,9 +826,7 @@ export class AgentSessionsSorter implements ITreeSorter { } //Sort by end or start time (most recent first) - const timeA = sessionA.timing.lastRequestEnded ?? sessionA.timing.lastRequestStarted ?? sessionA.timing.created; - const timeB = sessionB.timing.lastRequestEnded ?? sessionB.timing.lastRequestStarted ?? sessionB.timing.created; - return timeB - timeA; + return (sessionB.timing.endTime || sessionB.timing.startTime) - (sessionA.timing.endTime || sessionA.timing.startTime); } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentsControl.ts new file mode 100644 index 0000000000000..0a71dc15ccc8e --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentsControl.ts @@ -0,0 +1,290 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/focusView.css'; + +import { $, addDisposableListener, EventType, reset } from '../../../../../base/browser/dom.js'; +import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { localize } from '../../../../../nls.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { IFocusViewService } from './focusViewService.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { ExitFocusViewAction } from './focusViewActions.js'; +import { IAgentSessionsService } from './agentSessionsService.js'; +import { isSessionInProgressStatus } from './agentSessionsModel.js'; +import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { IAction } from '../../../../../base/common/actions.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; + +const TOGGLE_CHAT_ACTION_ID = 'workbench.action.chat.toggle'; +const OPEN_CHAT_ACTION_ID = 'workbench.action.chat.open'; // Has the keybinding +const QUICK_OPEN_ACTION_ID = 'workbench.action.quickOpenWithModes'; + +/** + * Agents Control View Item - renders agent status in the command center when agent session projection is enabled. + * + * Shows two different states: + * 1. Default state: Copilot icon pill (turns blue with in-progress count when agents are running) + * 2. Agent Session Projection state: Session title + close button (when viewing a session) + * + * The command center search box and navigation controls remain visible alongside this control. + */ +export class AgentsControlViewItem extends BaseActionViewItem { + + private _container: HTMLElement | undefined; + private readonly _dynamicDisposables = this._register(new DisposableStore()); + + constructor( + action: IAction, + options: IBaseActionViewItemOptions | undefined, + @IFocusViewService private readonly focusViewService: IFocusViewService, + @IHoverService private readonly hoverService: IHoverService, + @ICommandService private readonly commandService: ICommandService, + @IKeybindingService private readonly keybindingService: IKeybindingService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @ILabelService private readonly labelService: ILabelService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + ) { + super(undefined, action, options); + + // Re-render when session changes + this._register(this.focusViewService.onDidChangeActiveSession(() => { + this._render(); + })); + + this._register(this.focusViewService.onDidChangeFocusViewMode(() => { + this._render(); + })); + + // Re-render when sessions change to update statistics + this._register(this.agentSessionsService.model.onDidChangeSessions(() => { + this._render(); + })); + } + + override render(container: HTMLElement): void { + super.render(container); + this._container = container; + container.classList.add('agents-control-container'); + + // Initial render + this._render(); + } + + private _render(): void { + if (!this._container) { + return; + } + + // Clear existing content + reset(this._container); + + // Clear previous disposables for dynamic content + this._dynamicDisposables.clear(); + + if (this.focusViewService.isActive && this.focusViewService.activeSession) { + // Agent Session Projection mode - show session title + close button + this._renderSessionMode(this._dynamicDisposables); + } else { + // Default mode - show copilot pill with optional in-progress indicator + this._renderChatInputMode(this._dynamicDisposables); + } + } + + private _renderChatInputMode(disposables: DisposableStore): void { + if (!this._container) { + return; + } + + // Get agent session statistics + const sessions = this.agentSessionsService.model.sessions; + const activeSessions = sessions.filter(s => isSessionInProgressStatus(s.status)); + const unreadSessions = sessions.filter(s => !s.isRead()); + const hasActiveSessions = activeSessions.length > 0; + const hasUnreadSessions = unreadSessions.length > 0; + + // Create pill - add 'has-active' class when sessions are in progress + const pill = $('div.agents-control-pill.chat-input-mode'); + if (hasActiveSessions) { + pill.classList.add('has-active'); + } else if (hasUnreadSessions) { + pill.classList.add('has-unread'); + } + pill.setAttribute('role', 'button'); + pill.setAttribute('aria-label', localize('openChat', "Open Chat")); + pill.tabIndex = 0; + this._container.appendChild(pill); + + // Copilot icon (always shown) + const icon = $('span.agents-control-icon'); + reset(icon, renderIcon(Codicon.chatSparkle)); + pill.appendChild(icon); + + // Show workspace name (centered) + const label = $('span.agents-control-label'); + const workspaceName = this.labelService.getWorkspaceLabel(this.workspaceContextService.getWorkspace()); + label.textContent = workspaceName; + pill.appendChild(label); + + // Right side indicator + const rightIndicator = $('span.agents-control-status'); + if (hasActiveSessions) { + // Running indicator when there are active sessions + const runningIcon = $('span.agents-control-status-icon'); + reset(runningIcon, renderIcon(Codicon.sessionInProgress)); + rightIndicator.appendChild(runningIcon); + const runningCount = $('span.agents-control-status-text'); + runningCount.textContent = String(activeSessions.length); + rightIndicator.appendChild(runningCount); + } else if (hasUnreadSessions) { + // Unread indicator when there are unread sessions + const unreadIcon = $('span.agents-control-status-icon'); + reset(unreadIcon, renderIcon(Codicon.circleFilled)); + rightIndicator.appendChild(unreadIcon); + const unreadCount = $('span.agents-control-status-text'); + unreadCount.textContent = String(unreadSessions.length); + rightIndicator.appendChild(unreadCount); + } else { + // Keyboard shortcut when idle (show open chat keybinding) + const kb = this.keybindingService.lookupKeybinding(OPEN_CHAT_ACTION_ID)?.getLabel(); + if (kb) { + const kbLabel = $('span.agents-control-keybinding'); + kbLabel.textContent = kb; + rightIndicator.appendChild(kbLabel); + } + } + pill.appendChild(rightIndicator); + + // Setup hover with keyboard shortcut + const hoverDelegate = getDefaultHoverDelegate('mouse'); + const kbForTooltip = this.keybindingService.lookupKeybinding(OPEN_CHAT_ACTION_ID)?.getLabel(); + const tooltip = kbForTooltip + ? localize('askTooltip', "Open Chat ({0})", kbForTooltip) + : localize('askTooltip2', "Open Chat"); + disposables.add(this.hoverService.setupManagedHover(hoverDelegate, pill, tooltip)); + + // Click handler - open chat + disposables.add(addDisposableListener(pill, EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this.commandService.executeCommand(TOGGLE_CHAT_ACTION_ID); + })); + + // Keyboard handler + disposables.add(addDisposableListener(pill, EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this.commandService.executeCommand(TOGGLE_CHAT_ACTION_ID); + } + })); + + // Search button (right of pill) + this._renderSearchButton(disposables); + } + + private _renderSessionMode(disposables: DisposableStore): void { + if (!this._container) { + return; + } + + const pill = $('div.agents-control-pill.session-mode'); + this._container.appendChild(pill); + + // Copilot icon + const iconContainer = $('span.agents-control-icon'); + reset(iconContainer, renderIcon(Codicon.chatSparkle)); + pill.appendChild(iconContainer); + + // Session title + const titleLabel = $('span.agents-control-title'); + const session = this.focusViewService.activeSession; + titleLabel.textContent = session?.label ?? localize('agentSessionProjection', "Agent Session Projection"); + pill.appendChild(titleLabel); + + // Close button + const closeButton = $('span.agents-control-close'); + closeButton.classList.add('codicon', 'codicon-close'); + closeButton.setAttribute('role', 'button'); + closeButton.setAttribute('aria-label', localize('exitAgentSessionProjection', "Exit Agent Session Projection")); + closeButton.tabIndex = 0; + pill.appendChild(closeButton); + + // Setup hovers + const hoverDelegate = getDefaultHoverDelegate('mouse'); + disposables.add(this.hoverService.setupManagedHover(hoverDelegate, closeButton, localize('exitAgentSessionProjectionTooltip', "Exit Agent Session Projection (Escape)"))); + disposables.add(this.hoverService.setupManagedHover(hoverDelegate, pill, () => { + const activeSession = this.focusViewService.activeSession; + return activeSession ? localize('agentSessionProjectionTooltip', "Agent Session Projection: {0}", activeSession.label) : localize('agentSessionProjection', "Agent Session Projection"); + })); + + // Close button click handler + disposables.add(addDisposableListener(closeButton, EventType.MOUSE_DOWN, (e) => { + e.preventDefault(); + e.stopPropagation(); + this.commandService.executeCommand(ExitFocusViewAction.ID); + })); + + disposables.add(addDisposableListener(closeButton, EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this.commandService.executeCommand(ExitFocusViewAction.ID); + })); + + // Close button keyboard handler + disposables.add(addDisposableListener(closeButton, EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this.commandService.executeCommand(ExitFocusViewAction.ID); + } + })); + + // Search button (right of pill) + this._renderSearchButton(disposables); + } + + private _renderSearchButton(disposables: DisposableStore): void { + if (!this._container) { + return; + } + + const searchButton = $('span.agents-control-search'); + reset(searchButton, renderIcon(Codicon.search)); + searchButton.setAttribute('role', 'button'); + searchButton.setAttribute('aria-label', localize('openQuickOpen', "Open Quick Open")); + searchButton.tabIndex = 0; + this._container.appendChild(searchButton); + + // Setup hover + const hoverDelegate = getDefaultHoverDelegate('mouse'); + const searchKb = this.keybindingService.lookupKeybinding(QUICK_OPEN_ACTION_ID)?.getLabel(); + const searchTooltip = searchKb + ? localize('openQuickOpenTooltip', "Go to File ({0})", searchKb) + : localize('openQuickOpenTooltip2', "Go to File"); + disposables.add(this.hoverService.setupManagedHover(hoverDelegate, searchButton, searchTooltip)); + + // Click handler + disposables.add(addDisposableListener(searchButton, EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this.commandService.executeCommand(QUICK_OPEN_ACTION_ID); + })); + + // Keyboard handler + disposables.add(addDisposableListener(searchButton, EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this.commandService.executeCommand(QUICK_OPEN_ACTION_ID); + } + })); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/focusViewActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/focusViewActions.ts new file mode 100644 index 0000000000000..d76b5c2c967d2 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/focusViewActions.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize, localize2 } from '../../../../../nls.js'; +import { Action2, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { KeyCode } from '../../../../../base/common/keyCodes.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { ChatConfiguration } from '../../common/constants.js'; +import { IFocusViewService } from './focusViewService.js'; +import { IAgentSession, isMarshalledAgentSessionContext, IMarshalledAgentSessionContext } from './agentSessionsModel.js'; +import { IAgentSessionsService } from './agentSessionsService.js'; +import { CHAT_CATEGORY } from '../actions/chatActions.js'; +import { openSessionInChatWidget } from './agentSessionsOpener.js'; +import { ToggleTitleBarConfigAction } from '../../../../browser/parts/titlebar/titlebarActions.js'; +import { IsCompactTitleBarContext } from '../../../../common/contextkeys.js'; + +//#region Enter Agent Session Projection + +export class EnterFocusViewAction extends Action2 { + static readonly ID = 'agentSession.enterAgentSessionProjection'; + + constructor() { + super({ + id: EnterFocusViewAction.ID, + title: localize2('enterAgentSessionProjection', "Enter Agent Session Projection"), + category: CHAT_CATEGORY, + f1: false, + precondition: ContextKeyExpr.and( + ChatContextKeys.enabled, + ContextKeyExpr.has(`config.${ChatConfiguration.AgentSessionProjectionEnabled}`), + ChatContextKeys.inFocusViewMode.negate() + ), + }); + } + + override async run(accessor: ServicesAccessor, context?: IAgentSession | IMarshalledAgentSessionContext): Promise { + const focusViewService = accessor.get(IFocusViewService); + const agentSessionsService = accessor.get(IAgentSessionsService); + + let session: IAgentSession | undefined; + if (context) { + if (isMarshalledAgentSessionContext(context)) { + session = agentSessionsService.getSession(context.session.resource); + } else { + session = context; + } + } + + if (session) { + await focusViewService.enterFocusView(session); + } + } +} + +//#endregion + +//#region Exit Agent Session Projection + +export class ExitFocusViewAction extends Action2 { + static readonly ID = 'agentSession.exitAgentSessionProjection'; + + constructor() { + super({ + id: ExitFocusViewAction.ID, + title: localize2('exitAgentSessionProjection', "Exit Agent Session Projection"), + category: CHAT_CATEGORY, + f1: true, + precondition: ContextKeyExpr.and( + ChatContextKeys.enabled, + ChatContextKeys.inFocusViewMode + ), + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyCode.Escape, + when: ChatContextKeys.inFocusViewMode, + }, + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const focusViewService = accessor.get(IFocusViewService); + await focusViewService.exitFocusView(); + } +} + +//#endregion + +//#region Open in Chat Panel + +export class OpenInChatPanelAction extends Action2 { + static readonly ID = 'agentSession.openInChatPanel'; + + constructor() { + super({ + id: OpenInChatPanelAction.ID, + title: localize2('openInChatPanel', "Open in Chat Panel"), + category: CHAT_CATEGORY, + precondition: ChatContextKeys.enabled, + menu: [{ + id: MenuId.AgentSessionsContext, + group: '1_open', + order: 1, + }] + }); + } + + override async run(accessor: ServicesAccessor, context?: IAgentSession | IMarshalledAgentSessionContext): Promise { + const agentSessionsService = accessor.get(IAgentSessionsService); + + let session: IAgentSession | undefined; + if (context) { + if (isMarshalledAgentSessionContext(context)) { + session = agentSessionsService.getSession(context.session.resource); + } else { + session = context; + } + } + + if (session) { + await openSessionInChatWidget(accessor, session); + } + } +} + +//#endregion + +//#region Toggle Agents Control + +export class ToggleAgentsControl extends ToggleTitleBarConfigAction { + constructor() { + super( + ChatConfiguration.AgentSessionProjectionEnabled, + localize('toggle.agentsControl', 'Agents Controls'), + localize('toggle.agentsControlDescription', "Toggle visibility of the Agents Controls in title bar"), 6, + ContextKeyExpr.and( + ChatContextKeys.enabled, + IsCompactTitleBarContext.negate(), + ChatContextKeys.supported + ) + ); + } +} + +//#endregion diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/focusViewService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/focusViewService.ts new file mode 100644 index 0000000000000..cfcd09839dd86 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/focusViewService.ts @@ -0,0 +1,277 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/focusView.css'; + +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IEditorGroupsService, IEditorWorkingSet } from '../../../../services/editor/common/editorGroupsService.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { IAgentSession } from './agentSessionsModel.js'; +import { ChatViewPaneTarget, IChatWidgetService } from '../chat.js'; +import { AgentSessionProviders } from './agentSessions.js'; +import { IChatSessionsService } from '../../common/chatSessionsService.js'; +import { ChatConfiguration } from '../../common/constants.js'; +import { IWorkbenchLayoutService } from '../../../../services/layout/browser/layoutService.js'; +import { ACTION_ID_NEW_CHAT } from '../actions/chatActions.js'; + +//#region Configuration + +/** + * Provider types that support agent session projection mode. + * Only sessions from these providers will trigger focus view. + * + * Configuration: + * - AgentSessionProviders.Local: Local chat sessions (disabled) + * - AgentSessionProviders.Background: Background CLI agents (enabled) + * - AgentSessionProviders.Cloud: Cloud agents (enabled) + */ +const AGENT_SESSION_PROJECTION_ENABLED_PROVIDERS: Set = new Set([ + AgentSessionProviders.Background, + AgentSessionProviders.Cloud, +]); + +//#endregion + +//#region Focus View Service Interface + +export interface IFocusViewService { + readonly _serviceBrand: undefined; + + /** + * Whether focus view mode is active. + */ + readonly isActive: boolean; + + /** + * The currently active session in focus view, if any. + */ + readonly activeSession: IAgentSession | undefined; + + /** + * Event fired when focus view mode changes. + */ + readonly onDidChangeFocusViewMode: Event; + + /** + * Event fired when the active session changes (including when switching between sessions). + */ + readonly onDidChangeActiveSession: Event; + + /** + * Enter focus view mode for the given session. + */ + enterFocusView(session: IAgentSession): Promise; + + /** + * Exit focus view mode. + */ + exitFocusView(): Promise; +} + +export const IFocusViewService = createDecorator('focusViewService'); + +//#endregion + +//#region Focus View Service Implementation + +export class FocusViewService extends Disposable implements IFocusViewService { + + declare readonly _serviceBrand: undefined; + + private _isActive = false; + get isActive(): boolean { return this._isActive; } + + private _activeSession: IAgentSession | undefined; + get activeSession(): IAgentSession | undefined { return this._activeSession; } + + private readonly _onDidChangeFocusViewMode = this._register(new Emitter()); + readonly onDidChangeFocusViewMode = this._onDidChangeFocusViewMode.event; + + private readonly _onDidChangeActiveSession = this._register(new Emitter()); + readonly onDidChangeActiveSession = this._onDidChangeActiveSession.event; + + private readonly _inFocusViewModeContextKey: IContextKey; + + /** Working set saved when entering focus view (to restore on exit) */ + private _nonFocusViewWorkingSet: IEditorWorkingSet | undefined; + + /** Working sets per session, keyed by session resource URI string */ + private readonly _sessionWorkingSets = new Map(); + + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + @IEditorService private readonly editorService: IEditorService, + @ILogService private readonly logService: ILogService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @ICommandService private readonly commandService: ICommandService, + ) { + super(); + + this._inFocusViewModeContextKey = ChatContextKeys.inFocusViewMode.bindTo(contextKeyService); + + // Listen for editor close events to exit focus view when all editors are closed + this._register(this.editorService.onDidCloseEditor(() => this._checkForEmptyEditors())); + } + + private _isEnabled(): boolean { + return this.configurationService.getValue(ChatConfiguration.AgentSessionProjectionEnabled) === true; + } + + private _checkForEmptyEditors(): void { + // Only check if we're in focus view mode + if (!this._isActive) { + return; + } + + // Check if there are any visible editors + const hasVisibleEditors = this.editorService.visibleEditors.length > 0; + + if (!hasVisibleEditors) { + this.logService.trace('[FocusView] All editors closed, exiting focus view mode'); + this.exitFocusView(); + } + } + + private async _openSessionFiles(session: IAgentSession): Promise { + // Clear editors first + await this.editorGroupsService.applyWorkingSet('empty', { preserveFocus: true }); + + this.logService.trace(`[FocusView] Opening files for session '${session.label}'`, { + hasChanges: !!session.changes, + isArray: Array.isArray(session.changes), + changeCount: Array.isArray(session.changes) ? session.changes.length : 0 + }); + + // Open changes from the session as a multi-diff editor (like edit session view) + if (session.changes && Array.isArray(session.changes) && session.changes.length > 0) { + // Filter to changes that have both original and modified URIs for diff view + const diffResources = session.changes + .filter(change => change.originalUri) + .map(change => ({ + originalUri: change.originalUri!, + modifiedUri: change.modifiedUri + })); + + this.logService.trace(`[FocusView] Found ${diffResources.length} files with diffs to display`); + + if (diffResources.length > 0) { + // Open multi-diff editor showing all changes + await this.commandService.executeCommand('_workbench.openMultiDiffEditor', { + multiDiffSourceUri: session.resource.with({ scheme: session.resource.scheme + '-agent-session-projection' }), + title: localize('agentSessionProjection.changes.title', '{0} - All Changes', session.label), + resources: diffResources, + }); + + this.logService.trace(`[FocusView] Multi-diff editor opened successfully`); + + // Save this as the session's working set + const sessionKey = session.resource.toString(); + const newWorkingSet = this.editorGroupsService.saveWorkingSet(`focus-view-session-${sessionKey}`); + this._sessionWorkingSets.set(sessionKey, newWorkingSet); + } else { + this.logService.trace(`[FocusView] No files with diffs to display (all changes missing originalUri)`); + } + } else { + this.logService.trace(`[FocusView] Session has no changes to display`); + } + } + + async enterFocusView(session: IAgentSession): Promise { + // Check if the feature is enabled + if (!this._isEnabled()) { + this.logService.trace('[FocusView] Agent Session Projection is disabled'); + return; + } + + // Check if this session's provider type supports agent session projection + if (!AGENT_SESSION_PROJECTION_ENABLED_PROVIDERS.has(session.providerType)) { + this.logService.trace(`[FocusView] Provider type '${session.providerType}' does not support agent session projection`); + return; + } + + if (!this._isActive) { + // First time entering focus view - save the current working set as our "non-focus-view" backup + this._nonFocusViewWorkingSet = this.editorGroupsService.saveWorkingSet('focus-view-backup'); + } else if (this._activeSession) { + // Already in focus view, switching sessions - save the current session's working set + const previousSessionKey = this._activeSession.resource.toString(); + const previousWorkingSet = this.editorGroupsService.saveWorkingSet(`focus-view-session-${previousSessionKey}`); + this._sessionWorkingSets.set(previousSessionKey, previousWorkingSet); + } + + // Always open session files to ensure they're displayed + await this._openSessionFiles(session); + + // Set active state + const wasActive = this._isActive; + this._isActive = true; + this._activeSession = session; + this._inFocusViewModeContextKey.set(true); + this.layoutService.mainContainer.classList.add('focus-view-active'); + if (!wasActive) { + this._onDidChangeFocusViewMode.fire(true); + } + // Always fire session change event (for title updates when switching sessions) + this._onDidChangeActiveSession.fire(session); + + // Open the session in the chat panel + session.setRead(true); + await this.chatSessionsService.activateChatSessionItemProvider(session.providerType); + await this.chatWidgetService.openSession(session.resource, ChatViewPaneTarget, { + title: { preferred: session.label }, + revealIfOpened: true + }); + } + + async exitFocusView(): Promise { + if (!this._isActive) { + return; + } + + // Save the current session's working set before exiting + if (this._activeSession) { + const sessionKey = this._activeSession.resource.toString(); + const workingSet = this.editorGroupsService.saveWorkingSet(`focus-view-session-${sessionKey}`); + this._sessionWorkingSets.set(sessionKey, workingSet); + } + + // Restore the non-focus-view working set + if (this._nonFocusViewWorkingSet) { + const existingWorkingSets = this.editorGroupsService.getWorkingSets(); + const exists = existingWorkingSets.some(ws => ws.id === this._nonFocusViewWorkingSet!.id); + if (exists) { + await this.editorGroupsService.applyWorkingSet(this._nonFocusViewWorkingSet); + this.editorGroupsService.deleteWorkingSet(this._nonFocusViewWorkingSet); + } else { + await this.editorGroupsService.applyWorkingSet('empty', { preserveFocus: true }); + } + this._nonFocusViewWorkingSet = undefined; + } + + this._isActive = false; + this._activeSession = undefined; + this._inFocusViewModeContextKey.set(false); + this.layoutService.mainContainer.classList.remove('focus-view-active'); + this._onDidChangeFocusViewMode.fire(false); + this._onDidChangeActiveSession.fire(undefined); + + // Start a new chat to clear the sidebar + await this.commandService.executeCommand(ACTION_ID_NEW_CHAT); + } +} + +//#endregion diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/focusView.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/focusView.css new file mode 100644 index 0000000000000..bea8ba912b9e0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/focusView.css @@ -0,0 +1,230 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* ======================================== +Focus View Mode - Blue glow border around entire workbench +======================================== */ + +.monaco-workbench.focus-view-active::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + z-index: 10000; + box-shadow: inset 0 0 0 3px rgba(0, 120, 212, 0.8), inset 0 0 30px rgba(0, 120, 212, 0.4); + transition: box-shadow 0.2s ease-in-out; +} + +.hc-black .monaco-workbench.focus-view-active::after, +.hc-light .monaco-workbench.focus-view-active::after { + box-shadow: inset 0 0 0 2px var(--vscode-contrastBorder); +} + +/* ======================================== +Agents Control - Titlebar control +======================================== */ + +/* Hide command center search box when agents control enabled */ +.agents-control-enabled .command-center .action-item.command-center-center { + display: none !important; +} + +/* Give agents control same width as search box */ +.agents-control-enabled .command-center .action-item.agents-control-container { + width: 38vw; + max-width: 600px; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: center; +} + +.agents-control-container { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: center; + gap: 4px; + -webkit-app-region: no-drag; +} + +/* Pill - shared styles */ +.agents-control-pill { + display: flex; + align-items: center; + gap: 6px; + padding: 0 10px; + height: 22px; + border-radius: 6px; + position: relative; + flex: 1; + min-width: 0; + -webkit-app-region: no-drag; +} + +/* Chat input mode (default state) */ +.agents-control-pill.chat-input-mode { + background-color: var(--vscode-commandCenter-background, rgba(0, 0, 0, 0.05)); + border: 1px solid var(--vscode-commandCenter-border, transparent); + cursor: pointer; +} + +.agents-control-pill.chat-input-mode:hover { + background-color: var(--vscode-commandCenter-activeBackground, rgba(0, 0, 0, 0.1)); + border-color: var(--vscode-commandCenter-activeBorder, rgba(0, 0, 0, 0.2)); +} + +.agents-control-pill.chat-input-mode:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +/* Active state - has running sessions */ +.agents-control-pill.chat-input-mode.has-active { + background-color: rgba(0, 120, 212, 0.15); + border: 1px solid rgba(0, 120, 212, 0.5); +} + +.agents-control-pill.chat-input-mode.has-active:hover { + background-color: rgba(0, 120, 212, 0.25); + border-color: rgba(0, 120, 212, 0.7); +} + +.agents-control-pill.chat-input-mode.has-active .agents-control-icon, +.agents-control-pill.chat-input-mode.has-active .agents-control-label { + color: var(--vscode-textLink-foreground); + opacity: 1; +} + +/* Unread state - has unread sessions (no background change, just indicator) */ +.agents-control-pill.chat-input-mode.has-unread .agents-control-status-icon { + font-size: 8px; +} + +/* Session mode (viewing a session) */ +.agents-control-pill.session-mode { + background-color: rgba(0, 120, 212, 0.15); + border: 1px solid rgba(0, 120, 212, 0.5); + padding: 0 12px; +} + +.agents-control-pill.session-mode:hover { + background-color: rgba(0, 120, 212, 0.25); + border-color: rgba(0, 120, 212, 0.7); +} + +/* Icon */ +.agents-control-icon { + display: flex; + align-items: center; + color: var(--vscode-foreground); + opacity: 0.7; +} + +.agents-control-pill.session-mode .agents-control-icon { + color: var(--vscode-textLink-foreground); + opacity: 1; +} + +/* Label (workspace name, centered) */ +.agents-control-label { + flex: 1; + text-align: center; + color: var(--vscode-foreground); + opacity: 0.8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Right side status indicator */ +.agents-control-status { + position: absolute; + right: 8px; + display: flex; + align-items: center; + gap: 4px; + color: var(--vscode-descriptionForeground); +} + +.agents-control-pill.has-active .agents-control-status { + color: var(--vscode-textLink-foreground); +} + +.agents-control-status-icon { + display: flex; + align-items: center; +} + +.agents-control-status-text { + font-size: 11px; + font-weight: 500; +} + +.agents-control-keybinding { + font-size: 11px; + opacity: 0.7; +} + +/* Session title */ +.agents-control-title { + flex: 1; + font-weight: 500; + color: var(--vscode-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Close button */ +.agents-control-close { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 4px; + cursor: pointer; + color: var(--vscode-foreground); + opacity: 0.8; + margin-left: auto; + -webkit-app-region: no-drag; +} + +.agents-control-close:hover { + opacity: 1; + background-color: rgba(0, 0, 0, 0.1); +} + +.agents-control-close:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +/* Search button (right of pill) */ +.agents-control-search { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 4px; + cursor: pointer; + color: var(--vscode-foreground); + opacity: 0.7; + -webkit-app-region: no-drag; +} + +.agents-control-search:hover { + opacity: 1; + background-color: var(--vscode-commandCenter-activeBackground, rgba(0, 0, 0, 0.1)); +} + +.agents-control-search:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 4269ca6a4a818..5647d285ea38b 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -134,6 +134,7 @@ import { ChatViewsWelcomeHandler } from './viewsWelcome/chatViewsWelcomeHandler. import { ChatWidgetService } from './widget/chatWidgetService.js'; import { ILanguageModelsConfigurationService } from '../common/languageModelsConfiguration.js'; import { ChatWindowNotifier } from './chatWindowNotifier.js'; +import { ChatRepoInfoContribution } from './chatRepoInfo.js'; const toolReferenceNameEnumValues: string[] = []; const toolReferenceNameEnumDescriptions: string[] = []; @@ -188,6 +189,12 @@ configurationRegistry.registerConfiguration({ markdownDescription: nls.localize('chat.commandCenter.enabled', "Controls whether the command center shows a menu for actions to control chat (requires {0}).", '`#window.commandCenter#`'), default: true }, + [ChatConfiguration.AgentSessionProjectionEnabled]: { + type: 'boolean', + markdownDescription: nls.localize('chat.agentSessionProjection.enabled', "Controls whether Agent Session Projection mode is enabled for reviewing agent sessions in a focused workspace."), + default: false, + tags: ['experimental'] + }, 'chat.implicitContext.enabled': { type: 'object', description: nls.localize('chat.implicitContext.enabled.1', "Enables automatically using the active editor as chat context for specified chat locations."), @@ -1202,6 +1209,7 @@ registerWorkbenchContribution2(ChatEditingNotebookFileSystemProviderContrib.ID, registerWorkbenchContribution2(UserToolSetsContributions.ID, UserToolSetsContributions, WorkbenchPhase.Eventually); registerWorkbenchContribution2(PromptLanguageFeaturesProvider.ID, PromptLanguageFeaturesProvider, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatWindowNotifier.ID, ChatWindowNotifier, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(ChatRepoInfoContribution.ID, ChatRepoInfoContribution, WorkbenchPhase.Eventually); registerChatActions(); registerChatAccessibilityActions(); diff --git a/src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts b/src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts new file mode 100644 index 0000000000000..61636774433ba --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts @@ -0,0 +1,593 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { relativePath } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { linesDiffComputers } from '../../../../editor/common/diff/linesDiffComputers.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; +import { ISCMService, ISCMResource } from '../../scm/common/scm.js'; +import { IChatService } from '../common/chatService/chatService.js'; +import { ChatConfiguration } from '../common/constants.js'; +import { IChatModel, IExportableRepoData, IExportableRepoDiff } from '../common/model/chatModel.js'; +import * as nls from '../../../../nls.js'; + +const MAX_CHANGES = 100; +const MAX_DIFFS_SIZE_BYTES = 900 * 1024; +const MAX_SESSIONS_WITH_FULL_DIFFS = 5; +/** + * Regex to match `url = ` lines in git config. + */ +const RemoteMatcher = /^\s*url\s*=\s*(.+\S)\s*$/mg; + +/** + * Extracts raw remote URLs from git config content. + */ +function getRawRemotes(text: string): string[] { + const remotes: string[] = []; + let match: RegExpExecArray | null; + while (match = RemoteMatcher.exec(text)) { + remotes.push(match[1]); + } + return remotes; +} + +/** + * Extracts a hostname from a git remote URL. + * + * Supports: + * - URL-like remotes: https://github.com/..., ssh://git@github.com/..., git://github.com/... + * - SCP-like remotes: git@github.com:owner/repo.git + */ +function getRemoteHost(remoteUrl: string): string | undefined { + try { + // Try standard URL parsing first (works for https://, ssh://, git://) + const url = new URL(remoteUrl); + return url.hostname.toLowerCase(); + } catch { + // Fallback for SCP-like syntax: [user@]host:path + const atIndex = remoteUrl.lastIndexOf('@'); + const hostAndPath = atIndex !== -1 ? remoteUrl.slice(atIndex + 1) : remoteUrl; + const colonIndex = hostAndPath.indexOf(':'); + if (colonIndex !== -1) { + const host = hostAndPath.slice(0, colonIndex); + return host ? host.toLowerCase() : undefined; + } + + // Fallback for hostname/path format without scheme (e.g., devdiv.visualstudio.com/...) + const slashIndex = hostAndPath.indexOf('/'); + if (slashIndex !== -1) { + const host = hostAndPath.slice(0, slashIndex); + return host ? host.toLowerCase() : undefined; + } + + return undefined; + } +} + +/** + * Determines the change type based on SCM resource properties. + */ +function determineChangeType(resource: ISCMResource, groupId: string): 'added' | 'modified' | 'deleted' | 'renamed' { + const contextValue = resource.contextValue?.toLowerCase() ?? ''; + const groupIdLower = groupId.toLowerCase(); + + if (contextValue.includes('untracked') || contextValue.includes('add')) { + return 'added'; + } + if (contextValue.includes('delete')) { + return 'deleted'; + } + if (contextValue.includes('rename')) { + return 'renamed'; + } + if (groupIdLower.includes('untracked')) { + return 'added'; + } + if (resource.decorations.strikeThrough) { + return 'deleted'; + } + if (!resource.multiDiffEditorOriginalUri) { + return 'added'; + } + return 'modified'; +} + +/** + * Generates a unified diff string compatible with `git apply`. + */ +async function generateUnifiedDiff( + fileService: IFileService, + relPath: string, + originalUri: URI | undefined, + modifiedUri: URI, + changeType: 'added' | 'modified' | 'deleted' | 'renamed' +): Promise { + try { + let originalContent = ''; + let modifiedContent = ''; + + if (originalUri && changeType !== 'added') { + try { + const originalFile = await fileService.readFile(originalUri); + originalContent = originalFile.value.toString(); + } catch { + if (changeType === 'modified') { + return undefined; + } + } + } + + if (changeType !== 'deleted') { + try { + const modifiedFile = await fileService.readFile(modifiedUri); + modifiedContent = modifiedFile.value.toString(); + } catch { + return undefined; + } + } + + const originalLines = originalContent.split('\n'); + const modifiedLines = modifiedContent.split('\n'); + const diffLines: string[] = []; + const aPath = changeType === 'added' ? '/dev/null' : `a/${relPath}`; + const bPath = changeType === 'deleted' ? '/dev/null' : `b/${relPath}`; + + diffLines.push(`--- ${aPath}`); + diffLines.push(`+++ ${bPath}`); + + if (changeType === 'added') { + if (modifiedLines.length > 0) { + diffLines.push(`@@ -0,0 +1,${modifiedLines.length} @@`); + for (const line of modifiedLines) { + diffLines.push(`+${line}`); + } + } + } else if (changeType === 'deleted') { + if (originalLines.length > 0) { + diffLines.push(`@@ -1,${originalLines.length} +0,0 @@`); + for (const line of originalLines) { + diffLines.push(`-${line}`); + } + } + } else { + const hunks = computeDiffHunks(originalLines, modifiedLines); + for (const hunk of hunks) { + diffLines.push(hunk); + } + } + + return diffLines.join('\n'); + } catch { + return undefined; + } +} + +/** + * Computes unified diff hunks using VS Code's diff algorithm. + * Merges adjacent/overlapping hunks to produce a valid patch. + */ +function computeDiffHunks(originalLines: string[], modifiedLines: string[]): string[] { + const contextSize = 3; + const result: string[] = []; + + const diffComputer = linesDiffComputers.getDefault(); + const diffResult = diffComputer.computeDiff(originalLines, modifiedLines, { + ignoreTrimWhitespace: false, + maxComputationTimeMs: 1000, + computeMoves: false + }); + + if (diffResult.changes.length === 0) { + return result; + } + + // Group changes that should be merged into the same hunk + // Changes are merged if their context regions would overlap + type Change = typeof diffResult.changes[number]; + const hunkGroups: Change[][] = []; + let currentGroup: Change[] = []; + + for (const change of diffResult.changes) { + if (currentGroup.length === 0) { + currentGroup.push(change); + } else { + const lastChange = currentGroup[currentGroup.length - 1]; + const lastContextEnd = lastChange.original.endLineNumberExclusive - 1 + contextSize; + const currentContextStart = change.original.startLineNumber - contextSize; + + // Merge if context regions overlap or are adjacent + if (currentContextStart <= lastContextEnd + 1) { + currentGroup.push(change); + } else { + hunkGroups.push(currentGroup); + currentGroup = [change]; + } + } + } + if (currentGroup.length > 0) { + hunkGroups.push(currentGroup); + } + + // Generate a single hunk for each group + for (const group of hunkGroups) { + const firstChange = group[0]; + const lastChange = group[group.length - 1]; + + const hunkOrigStart = Math.max(1, firstChange.original.startLineNumber - contextSize); + const hunkOrigEnd = Math.min(originalLines.length, lastChange.original.endLineNumberExclusive - 1 + contextSize); + const hunkModStart = Math.max(1, firstChange.modified.startLineNumber - contextSize); + + const hunkLines: string[] = []; + let origLineNum = hunkOrigStart; + let origCount = 0; + let modCount = 0; + + // Process each change in the group, emitting context lines between them + for (const change of group) { + const origStart = change.original.startLineNumber; + const origEnd = change.original.endLineNumberExclusive; + const modStart = change.modified.startLineNumber; + const modEnd = change.modified.endLineNumberExclusive; + + // Emit context lines before this change + while (origLineNum < origStart) { + hunkLines.push(` ${originalLines[origLineNum - 1]}`); + origLineNum++; + origCount++; + modCount++; + } + + // Emit deleted lines + for (let i = origStart; i < origEnd; i++) { + hunkLines.push(`-${originalLines[i - 1]}`); + origLineNum++; + origCount++; + } + + // Emit added lines + for (let i = modStart; i < modEnd; i++) { + hunkLines.push(`+${modifiedLines[i - 1]}`); + modCount++; + } + } + + // Emit trailing context lines + while (origLineNum <= hunkOrigEnd) { + hunkLines.push(` ${originalLines[origLineNum - 1]}`); + origLineNum++; + origCount++; + modCount++; + } + + result.push(`@@ -${hunkOrigStart},${origCount} +${hunkModStart},${modCount} @@`); + result.push(...hunkLines); + } + + return result; +} + +/** + * Captures repository state from the first available SCM repository. + */ +export async function captureRepoInfo(scmService: ISCMService, fileService: IFileService): Promise { + const repositories = [...scmService.repositories]; + if (repositories.length === 0) { + return undefined; + } + + const repository = repositories[0]; + const rootUri = repository.provider.rootUri; + if (!rootUri) { + return undefined; + } + + let hasGit = false; + try { + const gitDirUri = rootUri.with({ path: `${rootUri.path}/.git` }); + hasGit = await fileService.exists(gitDirUri); + } catch { + // ignore + } + + if (!hasGit) { + return { + workspaceType: 'plain-folder', + syncStatus: 'no-git', + diffs: undefined + }; + } + + let remoteUrl: string | undefined; + try { + // TODO: Handle git worktrees where .git is a file pointing to the actual git directory + const gitConfigUri = rootUri.with({ path: `${rootUri.path}/.git/config` }); + const exists = await fileService.exists(gitConfigUri); + if (exists) { + const content = await fileService.readFile(gitConfigUri); + const remotes = getRawRemotes(content.value.toString()); + remoteUrl = remotes[0]; + } + } catch { + // ignore + } + + let localBranch: string | undefined; + let localHeadCommit: string | undefined; + let remoteTrackingBranch: string | undefined; + let remoteHeadCommit: string | undefined; + let remoteBaseBranch: string | undefined; + + const historyProvider = repository.provider.historyProvider?.get(); + if (historyProvider) { + const historyItemRef = historyProvider.historyItemRef.get(); + localBranch = historyItemRef?.name; + localHeadCommit = historyItemRef?.revision; + + const historyItemRemoteRef = historyProvider.historyItemRemoteRef.get(); + if (historyItemRemoteRef) { + remoteTrackingBranch = historyItemRemoteRef.name; + remoteHeadCommit = historyItemRemoteRef.revision; + } + + const historyItemBaseRef = historyProvider.historyItemBaseRef.get(); + if (historyItemBaseRef) { + remoteBaseBranch = historyItemBaseRef.name; + } + } + + let workspaceType: IExportableRepoData['workspaceType']; + let syncStatus: IExportableRepoData['syncStatus']; + + if (!remoteUrl) { + workspaceType = 'local-git'; + syncStatus = 'local-only'; + } else { + workspaceType = 'remote-git'; + + if (!remoteTrackingBranch) { + syncStatus = 'unpublished'; + } else if (localHeadCommit === remoteHeadCommit) { + syncStatus = 'synced'; + } else { + syncStatus = 'unpushed'; + } + } + + let remoteVendor: IExportableRepoData['remoteVendor']; + if (remoteUrl) { + const host = getRemoteHost(remoteUrl); + if (host === 'github.com') { + remoteVendor = 'github'; + } else if (host === 'dev.azure.com' || (host && host.endsWith('.visualstudio.com'))) { + remoteVendor = 'ado'; + } else { + remoteVendor = 'other'; + } + } + + let totalChangeCount = 0; + for (const group of repository.provider.groups) { + totalChangeCount += group.resources.length; + } + + const baseRepoData: Omit = { + workspaceType, + syncStatus, + remoteUrl, + remoteVendor, + localBranch, + remoteTrackingBranch, + remoteBaseBranch, + localHeadCommit, + remoteHeadCommit, + }; + + if (totalChangeCount === 0) { + return { + ...baseRepoData, + diffs: undefined, + diffsStatus: 'noChanges', + changedFileCount: 0 + }; + } + + if (totalChangeCount > MAX_CHANGES) { + return { + ...baseRepoData, + diffs: undefined, + diffsStatus: 'tooManyChanges', + changedFileCount: totalChangeCount + }; + } + + const diffs: IExportableRepoDiff[] = []; + const diffPromises: Promise[] = []; + + for (const group of repository.provider.groups) { + for (const resource of group.resources) { + const relPath = relativePath(rootUri, resource.sourceUri) ?? resource.sourceUri.path; + const changeType = determineChangeType(resource, group.id); + + const diffPromise = (async (): Promise => { + const unifiedDiff = await generateUnifiedDiff( + fileService, + relPath, + resource.multiDiffEditorOriginalUri, + resource.sourceUri, + changeType + ); + + return { + relativePath: relPath, + changeType, + status: group.label || group.id, + unifiedDiff + }; + })(); + + diffPromises.push(diffPromise); + } + } + + const generatedDiffs = await Promise.all(diffPromises); + for (const diff of generatedDiffs) { + if (diff) { + diffs.push(diff); + } + } + + const diffsJson = JSON.stringify(diffs); + const diffsSizeBytes = new TextEncoder().encode(diffsJson).length; + + if (diffsSizeBytes > MAX_DIFFS_SIZE_BYTES) { + return { + ...baseRepoData, + diffs: undefined, + diffsStatus: 'tooLarge', + changedFileCount: totalChangeCount + }; + } + + return { + ...baseRepoData, + diffs, + diffsStatus: 'included', + changedFileCount: totalChangeCount + }; +} + +/** + * Captures repository information for chat sessions on creation and first message. + */ +export class ChatRepoInfoContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.chatRepoInfo'; + + private _configurationRegistered = false; + + constructor( + @IChatService private readonly chatService: IChatService, + @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, + @ISCMService private readonly scmService: ISCMService, + @IFileService private readonly fileService: IFileService, + @ILogService private readonly logService: ILogService, + @IConfigurationService private readonly configurationService: IConfigurationService, + ) { + super(); + this.registerConfigurationIfInternal(); + this._register(this.chatEntitlementService.onDidChangeEntitlement(() => { + this.registerConfigurationIfInternal(); + })); + + this._register(this.chatService.onDidSubmitRequest(async ({ chatSessionResource }) => { + const model = this.chatService.getSession(chatSessionResource); + if (!model) { + return; + } + await this.captureAndSetRepoData(model); + })); + } + + private registerConfigurationIfInternal(): void { + if (this._configurationRegistered) { + return; + } + + if (!this.chatEntitlementService.isInternal) { + return; + } + + const registry = Registry.as(ConfigurationExtensions.Configuration); + registry.registerConfiguration({ + id: 'chatRepoInfo', + title: nls.localize('chatRepoInfoConfigurationTitle', "Chat Repository Info"), + type: 'object', + properties: { + [ChatConfiguration.RepoInfoEnabled]: { + type: 'boolean', + description: nls.localize('chat.repoInfo.enabled', "Controls whether repository information (branch, commit, working tree diffs) is captured at the start of chat sessions for internal diagnostics."), + default: true, + } + } + }); + + this._configurationRegistered = true; + this.logService.debug('[ChatRepoInfo] Configuration registered for internal user'); + } + + private async captureAndSetRepoData(model: IChatModel): Promise { + if (!this.chatEntitlementService.isInternal) { + return; + } + + // Check if repo info capture is enabled via configuration + if (!this.configurationService.getValue(ChatConfiguration.RepoInfoEnabled)) { + return; + } + + if (model.repoData) { + return; + } + + try { + const repoData = await captureRepoInfo(this.scmService, this.fileService); + if (repoData) { + model.setRepoData(repoData); + if (!repoData.localHeadCommit && repoData.workspaceType !== 'plain-folder') { + this.logService.warn('[ChatRepoInfo] Captured repo data without commit hash - git history may not be ready'); + } + + // Trim diffs from older sessions to manage storage + this.trimOldSessionDiffs(); + } else { + this.logService.debug('[ChatRepoInfo] No SCM repository available for chat session'); + } + } catch (error) { + this.logService.warn('[ChatRepoInfo] Failed to capture repo info:', error); + } + } + + /** + * Trims diffs from older sessions, keeping full diffs only for the most recent sessions. + */ + private trimOldSessionDiffs(): void { + try { + // Get all sessions with repoData that has diffs + const sessionsWithDiffs: { model: IChatModel; timestamp: number }[] = []; + + for (const model of this.chatService.chatModels.get()) { + if (model.repoData?.diffs && model.repoData.diffs.length > 0 && model.repoData.diffsStatus === 'included') { + sessionsWithDiffs.push({ model, timestamp: model.timestamp }); + } + } + + // Sort by timestamp descending (most recent first) + sessionsWithDiffs.sort((a, b) => b.timestamp - a.timestamp); + + // Trim diffs from sessions beyond the limit + for (let i = MAX_SESSIONS_WITH_FULL_DIFFS; i < sessionsWithDiffs.length; i++) { + const { model } = sessionsWithDiffs[i]; + if (model.repoData) { + const trimmedRepoData: IExportableRepoData = { + ...model.repoData, + diffs: undefined, + diffsStatus: 'trimmedForStorage' + }; + model.setRepoData(trimmedRepoData); + this.logService.trace(`[ChatRepoInfo] Trimmed diffs from older session: ${model.sessionResource.toString()}`); + } + } + } catch (error) { + this.logService.warn('[ChatRepoInfo] Failed to trim old session diffs:', error); + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index f6861a21ab542..8b41b6f025e60 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -37,14 +37,18 @@ import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { IChatEditorOptions } from '../widgetHosts/editor/chatEditor.js'; import { IChatModel } from '../../common/model/chatModel.js'; import { IChatService, IChatToolInvocation } from '../../common/chatService/chatService.js'; -import { autorun, autorunIterableDelta, observableSignalFromEvent } from '../../../../../base/common/observable.js'; +import { autorun, autorunIterableDelta, observableFromEvent, observableSignalFromEvent } from '../../../../../base/common/observable.js'; import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { renderAsPlaintext } from '../../../../../base/browser/markdownRenderer.js'; import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ChatViewId } from '../chat.js'; import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.js'; -import { AgentSessionProviders } from '../agentSessions/agentSessions.js'; +import { AgentSessionProviders, getAgentSessionProviderName } from '../agentSessions/agentSessions.js'; +import { BugIndicatingError } from '../../../../../base/common/errors.js'; +import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; +import { LocalChatSessionUri } from '../../common/model/chatUri.js'; +import { assertNever } from '../../../../../base/common/assert.js'; const extensionPoint = ExtensionsRegistry.registerExtensionPoint({ extensionPoint: 'chatSessions', @@ -313,6 +317,21 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ this._evaluateAvailability(); })); + const builtinSessionProviders = [AgentSessionProviders.Local]; + const contributedSessionProviders = observableFromEvent( + this.onDidChangeAvailability, + () => Array.from(this._contributions.keys()).filter(isAgentSessionProviderType) as AgentSessionProviders[], + ).recomputeInitiallyAndOnChange(this._store); + + this._register(autorun(reader => { + const activatedProviders = [...builtinSessionProviders, ...contributedSessionProviders.read(reader)]; + for (const provider of Object.values(AgentSessionProviders)) { + if (activatedProviders.includes(provider)) { + reader.store.add(registerNewSessionInPlaceAction(provider, getAgentSessionProviderName(provider))); + } + } + })); + this._register(this.onDidChangeSessionItems(chatSessionType => { this.updateInProgressStatus(chatSessionType).catch(error => { this._logService.warn(`Failed to update progress status for '${chatSessionType}':`, error); @@ -510,6 +529,8 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } private _registerCommands(contribution: IChatSessionsExtensionPoint): IDisposable { + const isAvailableInSessionTypePicker = isAgentSessionProviderType(contribution.type); + return combinedDisposable( registerAction2(class OpenChatSessionAction extends Action2 { constructor() { @@ -549,30 +570,8 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } async run(accessor: ServicesAccessor, chatOptions?: { prompt: string; attachedContext?: IChatRequestVariableEntry[] }): Promise { - const editorService = accessor.get(IEditorService); - const logService = accessor.get(ILogService); - const chatService = accessor.get(IChatService); - const { type } = contribution; - - try { - const options: IChatEditorOptions = { - override: ChatEditorInput.EditorID, - pinned: true, - title: { - fallback: localize('chatEditorContributionName', "{0}", contribution.displayName), - } - }; - const resource = URI.from({ - scheme: type, - path: `/untitled-${generateUuid()}`, - }); - await editorService.openEditor({ resource, options }); - if (chatOptions?.prompt) { - await chatService.sendRequest(resource, chatOptions.prompt, { agentIdSilent: type, attachedContext: chatOptions.attachedContext }); - } - } catch (e) { - logService.error(`Failed to open new '${type}' chat session editor`, e); - } + const { type, displayName } = contribution; + await openChatSession(accessor, { type, displayName, position: ChatSessionPosition.Editor }, chatOptions); } }), // New chat in sidebar chat (+ button) @@ -585,34 +584,16 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ icon: Codicon.plus, f1: false, // Hide from Command Palette precondition: ChatContextKeys.enabled, - menu: { + menu: !isAvailableInSessionTypePicker ? { id: MenuId.ChatNewMenu, group: '3_new_special', - } + } : undefined, }); } async run(accessor: ServicesAccessor, chatOptions?: { prompt: string; attachedContext?: IChatRequestVariableEntry[] }): Promise { - const viewsService = accessor.get(IViewsService); - const logService = accessor.get(ILogService); - const chatService = accessor.get(IChatService); - const { type } = contribution; - - try { - const resource = URI.from({ - scheme: type, - path: `/untitled-${generateUuid()}`, - }); - - const view = await viewsService.openView(ChatViewId) as ChatViewPane; - await view.loadSession(resource); - if (chatOptions?.prompt) { - await chatService.sendRequest(resource, chatOptions.prompt, { agentIdSilent: type, attachedContext: chatOptions.attachedContext }); - } - view.focus(); - } catch (e) { - logService.error(`Failed to open new '${type}' chat session in sidebar`, e); - } + const { type, displayName } = contribution; + await openChatSession(accessor, { type, displayName, position: ChatSessionPosition.Sidebar }, chatOptions); } }) ); @@ -1096,3 +1077,130 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } registerSingleton(IChatSessionsService, ChatSessionsService, InstantiationType.Delayed); + +function registerNewSessionInPlaceAction(type: string, displayName: string): IDisposable { + return registerAction2(class NewChatSessionInPlaceAction extends Action2 { + constructor() { + super({ + id: `workbench.action.chat.openNewChatSessionInPlace.${type}`, + title: localize2('interactiveSession.openNewChatSessionInPlace', "New {0}", displayName), + category: CHAT_CATEGORY, + f1: false, + precondition: ChatContextKeys.enabled, + }); + } + + // Expected args: [chatSessionPosition: 'sidebar' | 'editor'] + async run(accessor: ServicesAccessor, ...args: unknown[]): Promise { + if (args.length === 0) { + throw new BugIndicatingError('Expected chat session position argument'); + } + + const chatSessionPosition = args[0]; + if (chatSessionPosition !== ChatSessionPosition.Sidebar && chatSessionPosition !== ChatSessionPosition.Editor) { + throw new BugIndicatingError(`Invalid chat session position argument: ${chatSessionPosition}`); + } + + await openChatSession(accessor, { type: type, displayName: localize('chat', "Chat"), position: chatSessionPosition, replaceEditor: true }); + } + }); +} + +enum ChatSessionPosition { + Editor = 'editor', + Sidebar = 'sidebar' +} + +type NewChatSessionSendOptions = { + readonly prompt: string; + readonly attachedContext?: IChatRequestVariableEntry[]; +}; + +type NewChatSessionOpenOptions = { + readonly type: string; + readonly position: ChatSessionPosition; + readonly displayName: string; + readonly chatResource?: UriComponents; + readonly replaceEditor?: boolean; +}; + +async function openChatSession(accessor: ServicesAccessor, openOptions: NewChatSessionOpenOptions, chatSendOptions?: NewChatSessionSendOptions): Promise { + const viewsService = accessor.get(IViewsService); + const chatService = accessor.get(IChatService); + const logService = accessor.get(ILogService); + const editorGroupService = accessor.get(IEditorGroupsService); + const editorService = accessor.get(IEditorService); + + // Determine resource to open + const resource = getResourceForNewChatSession(openOptions); + + // Open chat session + try { + switch (openOptions.position) { + case ChatSessionPosition.Sidebar: { + const view = await viewsService.openView(ChatViewId) as ChatViewPane; + await view.loadSession(resource); + view.focus(); + break; + } + case ChatSessionPosition.Editor: { + const options: IChatEditorOptions = { + override: ChatEditorInput.EditorID, + pinned: true, + title: { + fallback: localize('chatEditorContributionName', "{0}", openOptions.displayName), + } + }; + if (openOptions.replaceEditor) { + // TODO: Do not rely on active editor + const activeEditor = editorGroupService.activeGroup.activeEditor; + if (!activeEditor || !(activeEditor instanceof ChatEditorInput)) { + throw new Error('No active chat editor to replace'); + } + await editorService.replaceEditors([{ editor: activeEditor, replacement: { resource, options } }], editorGroupService.activeGroup); + } else { + await editorService.openEditor({ resource, options }); + } + break; + } + default: assertNever(openOptions.position, `Unknown chat session position: ${openOptions.position}`); + } + } catch (e) { + logService.error(`Failed to open '${openOptions.type}' chat session with openOptions: ${JSON.stringify(openOptions)}`, e); + return; + } + + // Send initial prompt if provided + if (chatSendOptions) { + try { + await chatService.sendRequest(resource, chatSendOptions.prompt, { agentIdSilent: openOptions.type, attachedContext: chatSendOptions.attachedContext }); + } catch (e) { + logService.error(`Failed to send initial request to '${openOptions.type}' chat session with contextOptions: ${JSON.stringify(chatSendOptions)}`, e); + } + } +} + +function getResourceForNewChatSession(options: NewChatSessionOpenOptions): URI { + if (options.chatResource) { + return URI.revive(options.chatResource); + } + + const isRemoteSession = options.type !== AgentSessionProviders.Local; + if (isRemoteSession) { + return URI.from({ + scheme: options.type, + path: `/untitled-${generateUuid()}`, + }); + } + + const isEditorPosition = options.position === ChatSessionPosition.Editor; + if (isEditorPosition) { + return ChatEditorInput.getNewEditorUri(); + } + + return LocalChatSessionUri.forSession(generateUuid()); +} + +function isAgentSessionProviderType(type: string): boolean { + return Object.values(AgentSessionProviders).includes(type as AgentSessionProviders); +} diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItemtest.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.ts similarity index 100% rename from src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItemtest.ts rename to src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.ts diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index b76274ea66596..ab2fd90106a4c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -93,10 +93,10 @@ import { ChatAgentLocation, ChatConfiguration, ChatModeKind, validateChatMode } import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../common/languageModels.js'; import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; import { ActionLocation, ChatContinueInSessionActionItem, ContinueChatInSessionAction } from '../../actions/chatContinueInAction.js'; -import { ChatOpenModelPickerActionId, ChatSessionPrimaryPickerAction, ChatSubmitAction, IChatExecuteActionContext, OpenModePickerAction } from '../../actions/chatExecuteActions.js'; +import { ChatSessionPrimaryPickerAction, ChatSubmitAction, IChatExecuteActionContext, OpenModelPickerAction, OpenModePickerAction, OpenSessionTargetPickerAction } from '../../actions/chatExecuteActions.js'; import { IAgentSessionsService } from '../../agentSessions/agentSessionsService.js'; import { ImplicitContextAttachmentWidget } from '../../attachments/implicitContextAttachment.js'; -import { IChatWidget } from '../../chat.js'; +import { IChatWidget, isIChatResourceViewContext } from '../../chat.js'; import { ChatAttachmentModel } from '../../attachments/chatAttachmentModel.js'; import { DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmentWidget, ImageAttachmentWidget, NotebookCellOutputChatAttachmentWidget, PasteAttachmentWidget, PromptFileAttachmentWidget, PromptTextAttachmentWidget, SCMHistoryItemAttachmentWidget, SCMHistoryItemChangeAttachmentWidget, SCMHistoryItemChangeRangeAttachmentWidget, TerminalCommandAttachmentWidget, ToolSetOrToolItemAttachmentWidget } from '../../attachments/chatAttachmentWidgets.js'; import { IDisposableReference } from '../chatContentParts/chatCollections.js'; @@ -114,7 +114,9 @@ import { ChatRelatedFiles } from '../../attachments/chatInputRelatedFilesContrib import { resizeImage } from '../../chatImageUtils.js'; import { IModelPickerDelegate, ModelPickerActionItem } from './modelPickerActionItem.js'; import { IModePickerDelegate, ModePickerActionItem } from './modePickerActionItem.js'; -import { SearchableOptionPickerActionItem } from '../../chatSessions/searchableOptionPickerActionItemtest.js'; +import { ISessionTypePickerDelegate, SessionTypePickerActionItem } from './sessionTargetPickerActionItem.js'; +import { getAgentSessionProvider } from '../../agentSessions/agentSessions.js'; +import { SearchableOptionPickerActionItem } from '../../chatSessions/searchableOptionPickerActionItem.js'; import { mixin } from '../../../../../../base/common/objects.js'; const $ = dom.$; @@ -333,6 +335,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private chatSessionOptionsValid: IContextKey; private modelWidget: ModelPickerActionItem | undefined; private modeWidget: ModePickerActionItem | undefined; + private sessionTargetWidget: SessionTypePickerActionItem | undefined; private chatSessionPickerWidgets: Map = new Map(); private chatSessionPickerContainer: HTMLElement | undefined; private _lastSessionPickerAction: MenuItemAction | undefined; @@ -709,6 +712,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.modeWidget?.show(); } + public openSessionTargetPicker(): void { + this.sessionTargetWidget?.show(); + } + public openChatSessionPicker(): void { // Open the first available picker widget const firstWidget = this.chatSessionPickerWidgets?.values()?.next().value; @@ -1756,7 +1763,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge hiddenItemStrategy: HiddenItemStrategy.NoHide, hoverDelegate, actionViewItemProvider: (action, options) => { - if (action.id === ChatOpenModelPickerActionId && action instanceof MenuItemAction) { + if (action.id === OpenModelPickerAction.ID && action instanceof MenuItemAction) { if (!this._currentLanguageModel) { this.setCurrentLanguageModelToDefault(); } @@ -1778,6 +1785,15 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge sessionResource: () => this._widget?.viewModel?.sessionResource, }; return this.modeWidget = this.instantiationService.createInstance(ModePickerActionItem, action, delegate); + } else if (action.id === OpenSessionTargetPickerAction.ID && action instanceof MenuItemAction) { + const delegate: ISessionTypePickerDelegate = { + getActiveSessionProvider: () => { + const sessionResource = this._widget?.viewModel?.sessionResource; + return sessionResource ? getAgentSessionProvider(sessionResource) : undefined; + }, + }; + const chatSessionPosition = isIChatResourceViewContext(widget.viewContext) ? 'editor' : 'sidebar'; + return this.sessionTargetWidget = this.instantiationService.createInstance(SessionTypePickerActionItem, action, chatSessionPosition, delegate); } else if (action.id === ChatSessionPrimaryPickerAction.ID && action instanceof MenuItemAction) { // Create all pickers and return a container action view item const widgets = this.createChatSessionPickerWidgets(action); @@ -2177,6 +2193,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._chatInputTodoListWidget.value?.clear(sessionResource, force); } + setWorkingSetCollapsed(collapsed: boolean): void { + this._workingSetCollapsed.set(collapsed, undefined); + } + renderChatEditingSessionState(chatEditingSession: IChatEditingSession | null) { dom.setVisibility(Boolean(chatEditingSession), this.chatEditingSessionWidgetContainer); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts index b4a6c1b98ebf2..2059f7c902b10 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts @@ -154,12 +154,12 @@ export class ModePickerActionItem extends ActionWidgetDropdownActionViewItem { protected override renderLabel(element: HTMLElement): IDisposable | null { this.setAriaLabelAttributes(element); const state = this.delegate.currentMode.get().label.get(); - dom.reset(element, dom.$('span.chat-model-label', undefined, state), ...renderLabelWithIcons(`$(chevron-down)`)); + dom.reset(element, dom.$('span.chat-input-picker-label', undefined, state), ...renderLabelWithIcons(`$(chevron-down)`)); return null; } override render(container: HTMLElement): void { super.render(container); - container.classList.add('chat-modelPicker-item'); + container.classList.add('chat-input-picker-item'); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts index dfb4c2aa262ad..4cef0ffb4ec12 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts @@ -192,7 +192,7 @@ export class ModelPickerActionItem extends ActionWidgetDropdownActionViewItem { domChildren.push(iconElement); } - domChildren.push(dom.$('span.chat-model-label', undefined, name ?? localize('chat.modelPicker.auto', "Auto"))); + domChildren.push(dom.$('span.chat-input-picker-label', undefined, name ?? localize('chat.modelPicker.auto', "Auto"))); domChildren.push(...renderLabelWithIcons(`$(chevron-down)`)); dom.reset(element, ...domChildren); @@ -202,6 +202,6 @@ export class ModelPickerActionItem extends ActionWidgetDropdownActionViewItem { override render(container: HTMLElement): void { super.render(container); - container.classList.add('chat-modelPicker-item'); + container.classList.add('chat-input-picker-item'); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts new file mode 100644 index 0000000000000..cd5245be9db6c --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts @@ -0,0 +1,150 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../base/browser/dom.js'; +import { renderLabelWithIcons } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { IAction } from '../../../../../../base/common/actions.js'; +import { IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { localize } from '../../../../../../nls.js'; +import { ActionWidgetDropdownActionViewItem } from '../../../../../../platform/actions/browser/actionWidgetDropdownActionViewItem.js'; +import { MenuItemAction } from '../../../../../../platform/actions/common/actions.js'; +import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; +import { IActionWidgetDropdownAction, IActionWidgetDropdownActionProvider, IActionWidgetDropdownOptions } from '../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; +import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; +import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { IChatSessionsService } from '../../../common/chatSessionsService.js'; +import { AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderIcon, getAgentSessionProviderName } from '../../agentSessions/agentSessions.js'; + +export interface ISessionTypePickerDelegate { + getActiveSessionProvider(): AgentSessionProviders | undefined; +} + +interface ISessionTypeItem { + type: AgentSessionProviders; + label: string; + description: string; + commandId: string; +} + +/** + * Action view item for selecting a session target in the chat interface. + * This picker allows switching between different chat session types contributed via extensions. + */ +export class SessionTypePickerActionItem extends ActionWidgetDropdownActionViewItem { + private _sessionTypeItems: ISessionTypeItem[] = []; + + constructor( + action: MenuItemAction, + private readonly chatSessionPosition: 'sidebar' | 'editor', + private readonly delegate: ISessionTypePickerDelegate, + @IActionWidgetService actionWidgetService: IActionWidgetService, + @IKeybindingService keybindingService: IKeybindingService, + @IContextKeyService contextKeyService: IContextKeyService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @ICommandService private readonly commandService: ICommandService, + @IOpenerService openerService: IOpenerService, + ) { + const actionProvider: IActionWidgetDropdownActionProvider = { + getActions: () => { + const currentType = this.delegate.getActiveSessionProvider(); + + const actions: IActionWidgetDropdownAction[] = []; + for (const sessionTypeItem of this._sessionTypeItems) { + actions.push({ + ...action, + id: sessionTypeItem.commandId, + label: sessionTypeItem.label, + tooltip: sessionTypeItem.description, + checked: currentType === sessionTypeItem.type, + icon: getAgentSessionProviderIcon(sessionTypeItem.type), + enabled: true, + run: async () => { + this.commandService.executeCommand(sessionTypeItem.commandId, this.chatSessionPosition); + if (this.element) { + this.renderLabel(this.element); + } + }, + }); + } + + return actions; + } + }; + + const actionBarActions: IAction[] = []; + + const learnMoreUrl = 'https://code.visualstudio.com/docs/copilot/agents/overview'; + actionBarActions.push({ + id: 'workbench.action.chat.agentOverview.learnMore', + label: localize('chat.learnMore', "Learn about agent types..."), + tooltip: learnMoreUrl, + class: undefined, + enabled: true, + run: async () => { + await openerService.open(URI.parse(learnMoreUrl)); + } + }); + + const sessionTargetPickerOptions: Omit = { + actionProvider, + actionBarActions, + actionBarActionProvider: undefined, + showItemKeybindings: true, + }; + + super(action, sessionTargetPickerOptions, actionWidgetService, keybindingService, contextKeyService); + + this._updateAgentSessionItems(); + this._register(this.chatSessionsService.onDidChangeAvailability(() => { + this._updateAgentSessionItems(); + })); + } + + private _updateAgentSessionItems(): void { + const localSessionItem = { + type: AgentSessionProviders.Local, + label: getAgentSessionProviderName(AgentSessionProviders.Local), + description: localize('chat.sessionTarget.local.description', "Local chat session"), + commandId: `workbench.action.chat.openNewChatSessionInPlace.${AgentSessionProviders.Local}`, + }; + + const agentSessionItems = [localSessionItem]; + + const contributions = this.chatSessionsService.getAllChatSessionContributions(); + for (const contribution of contributions) { + const agentSessionType = getAgentSessionProvider(contribution.type); + if (!agentSessionType) { + continue; + } + + agentSessionItems.push({ + type: agentSessionType, + label: getAgentSessionProviderName(agentSessionType), + description: contribution.description, + commandId: `workbench.action.chat.openNewChatSessionInPlace.${contribution.type}`, + }); + } + this._sessionTypeItems = agentSessionItems; + } + + protected override renderLabel(element: HTMLElement): IDisposable | null { + this.setAriaLabelAttributes(element); + const currentType = this.delegate.getActiveSessionProvider(); + + const label = getAgentSessionProviderName(currentType ?? AgentSessionProviders.Local); + const icon = getAgentSessionProviderIcon(currentType ?? AgentSessionProviders.Local); + + dom.reset(element, ...renderLabelWithIcons(`$(${icon.id})`), dom.$('span.chat-input-picker-label', undefined, label), ...renderLabelWithIcons(`$(chevron-down)`)); + return null; + } + + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('chat-input-picker-item'); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 9ae784f8b3737..841d369223535 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -1344,13 +1344,13 @@ have to be updated for changes to the rules above, or to support more deeply nes overflow: hidden; min-width: 0px; - .chat-modelPicker-item { + .chat-input-picker-item { min-width: 0px; .action-label { min-width: 0px; - .chat-model-label { + .chat-input-picker-label { overflow: hidden; text-overflow: ellipsis; } @@ -1359,9 +1359,19 @@ have to be updated for changes to the rules above, or to support more deeply nes color: var(--vscode-problemsWarningIcon-foreground); } - span + .chat-model-label { + span + .chat-input-picker-label { margin-left: 2px; } + + .codicon { + font-size: 12px; + } + } + + .action-label.disabled { + .codicon { + color: var(--vscode-disabledForeground); + } } .codicon { @@ -1374,7 +1384,7 @@ have to be updated for changes to the rules above, or to support more deeply nes box-shadow: var(--vscode-scrollbar-shadow) 0 6px 6px -6px inset } -.interactive-session .chat-input-toolbar .chat-modelPicker-item .action-label, +.interactive-session .chat-input-toolbar .chat-input-picker-item .action-label, .interactive-session .chat-input-toolbar .chat-sessionPicker-item .action-label { height: 16px; padding: 3px 0px 3px 6px; @@ -1383,7 +1393,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } -.interactive-session .chat-input-toolbar .chat-modelPicker-item .action-label .codicon-chevron-down, +.interactive-session .chat-input-toolbar .chat-input-picker-item .action-label .codicon-chevron-down, .interactive-session .chat-input-toolbar .chat-sessionPicker-item .action-label .codicon-chevron-down { font-size: 12px; margin-left: 2px; diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index de27b8f6f4dc3..cd53193efa5b5 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -750,7 +750,13 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { const newModelRef = await this.chatService.loadSessionForResource(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); clearWidget.dispose(); await queue; - return this.showModel(newModelRef); + + const chatModel = await this.showModel(newModelRef); + if (chatModel) { + this._widget.input.setWorkingSetCollapsed(false); + } + + return chatModel; }); } diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 5f7e826e76bbb..d9033dd9f6faf 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -105,6 +105,9 @@ export namespace ChatContextKeys { export const hasAgentSessionChanges = new RawContextKey('agentSessionHasChanges', false, { type: 'boolean', description: localize('agentSessionHasChanges', "True when the current agent session item has changes.") }); export const isKatexMathElement = new RawContextKey('chatIsKatexMathElement', false, { type: 'boolean', description: localize('chatIsKatexMathElement', "True when focusing a KaTeX math element.") }); + + // Focus View mode + export const inFocusViewMode = new RawContextKey('chatInFocusViewMode', false, { type: 'boolean', description: localize('chatInFocusViewMode', "True when the workbench is in focus view mode for an agent session.") }); } export namespace ChatContextKeyExprs { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 6986780910b17..b4f75cc832f1e 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -941,24 +941,8 @@ export interface IChatSessionStats { } export interface IChatSessionTiming { - /** - * Timestamp when the session was created in milliseconds elapsed since January 1, 1970 00:00:00 UTC. - */ - created: number; - - /** - * Timestamp when the most recent request started in milliseconds elapsed since January 1, 1970 00:00:00 UTC. - * - * Should be undefined if no requests have been made yet. - */ - lastRequestStarted: number | undefined; - - /** - * Timestamp when the most recent request completed in milliseconds elapsed since January 1, 1970 00:00:00 UTC. - * - * Should be undefined if the most recent request is still in progress or if no requests have been made yet. - */ - lastRequestEnded: number | undefined; + startTime: number; + endTime?: number; } export const enum ResponseModelState { @@ -1051,6 +1035,8 @@ export interface IChatService { readonly onDidSubmitRequest: Event<{ readonly chatSessionResource: URI }>; + readonly onDidCreateModel: Event; + /** * An observable containing all live chat models. */ diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index e5d90f3d715b8..e515c29b76d8c 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -87,6 +87,8 @@ export class ChatService extends Disposable implements IChatService { private readonly _onDidSubmitRequest = this._register(new Emitter<{ readonly chatSessionResource: URI }>()); public readonly onDidSubmitRequest = this._onDidSubmitRequest.event; + public get onDidCreateModel() { return this._sessionModels.onDidCreateModel; } + private readonly _onDidPerformUserAction = this._register(new Emitter()); public readonly onDidPerformUserAction: Event = this._onDidPerformUserAction.event; @@ -375,11 +377,7 @@ export class ChatService extends Disposable implements IChatService { ...entry, sessionResource, // TODO@roblourens- missing for old data- normalize inside the store - timing: entry.timing ?? { - created: entry.lastMessageDate, - lastRequestStarted: undefined, - lastRequestEnded: entry.lastMessageDate, - }, + timing: entry.timing ?? { startTime: entry.lastMessageDate }, isActive: this._sessionModels.has(sessionResource), // TODO@roblourens- missing for old data- normalize inside the store lastResponseState: entry.lastResponseState ?? ResponseModelState.Complete, @@ -395,11 +393,7 @@ export class ChatService extends Disposable implements IChatService { ...metadata, sessionResource, // TODO@roblourens- missing for old data- normalize inside the store - timing: metadata.timing ?? { - created: metadata.lastMessageDate, - lastRequestStarted: undefined, - lastRequestEnded: metadata.lastMessageDate, - }, + timing: metadata.timing ?? { startTime: metadata.lastMessageDate }, isActive: this._sessionModels.has(sessionResource), // TODO@roblourens- missing for old data- normalize inside the store lastResponseState: metadata.lastResponseState ?? ResponseModelState.Complete, diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 94126a5ffcf9f..76a9b34869810 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -14,7 +14,7 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta import { IChatAgentAttachmentCapabilities, IChatAgentRequest } from './participants/chatAgents.js'; import { IChatEditingSession } from './editing/chatEditingService.js'; import { IChatModel, IChatRequestVariableData, ISerializableChatModelInputState } from './model/chatModel.js'; -import { IChatProgress, IChatService, IChatSessionTiming } from './chatService/chatService.js'; +import { IChatProgress, IChatService } from './chatService/chatService.js'; export const enum ChatSessionStatus { Failed = 0, @@ -73,7 +73,6 @@ export interface IChatSessionsExtensionPoint { readonly commands?: IChatSessionCommandContribution[]; readonly canDelegate?: boolean; } - export interface IChatSessionItem { resource: URI; label: string; @@ -82,7 +81,10 @@ export interface IChatSessionItem { description?: string | IMarkdownString; status?: ChatSessionStatus; tooltip?: string | IMarkdownString; - timing: IChatSessionTiming; + timing: { + startTime: number; + endTime?: number; + }; changes?: { files: number; insertions: number; diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 2ece134ffd732..e9b27d99b4c2f 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -10,8 +10,10 @@ import { RawContextKey } from '../../../../platform/contextkey/common/contextkey export enum ChatConfiguration { AgentEnabled = 'chat.agent.enabled', + AgentSessionProjectionEnabled = 'chat.agentSessionProjection.enabled', Edits2Enabled = 'chat.edits2.enabled', ExtensionToolsEnabled = 'chat.extensionTools.enabled', + RepoInfoEnabled = 'chat.repoInfo.enabled', EditRequests = 'chat.editRequests', GlobalAutoApprove = 'chat.tools.global.autoApprove', AutoApproveEdits = 'chat.tools.edits.autoApprove', diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 2a5cc4df78e6e..6115e54dbbe4a 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -1253,6 +1253,9 @@ export interface IChatModel extends IDisposable { toExport(): IExportableChatData; toJSON(): ISerializableChatData; readonly contributedChatSession: IChatSessionContext | undefined; + + readonly repoData: IExportableRepoData | undefined; + setRepoData(data: IExportableRepoData | undefined): void; } export interface ISerializableChatsData { @@ -1304,6 +1307,102 @@ export interface ISerializableMarkdownInfo { readonly suggestionId: EditSuggestionId; } +/** + * Repository state captured for chat session export. + * Enables reproducing the workspace state by cloning, checking out the commit, and applying diffs. + */ +export interface IExportableRepoData { + /** + * Classification of the workspace's version control state. + * - `remote-git`: Git repo with a configured remote URL + * - `local-git`: Git repo without any remote (local only) + * - `plain-folder`: Not a git repository + */ + workspaceType: 'remote-git' | 'local-git' | 'plain-folder'; + + /** + * Sync status between local and remote. + * - `synced`: Local HEAD matches remote tracking branch (fully pushed) + * - `unpushed`: Local has commits not pushed to the remote tracking branch + * - `unpublished`: Local branch has no remote tracking branch configured + * - `local-only`: No remote configured (local git repo only) + * - `no-git`: Not a git repository + */ + syncStatus: 'synced' | 'unpushed' | 'unpublished' | 'local-only' | 'no-git'; + + /** + * Remote URL of the repository (e.g., https://github.com/org/repo.git). + * Undefined if no remote is configured. + */ + remoteUrl?: string; + + /** + * Vendor/host of the remote repository. + * Undefined if no remote is configured. + */ + remoteVendor?: 'github' | 'ado' | 'other'; + + /** + * Remote tracking branch for the current branch (e.g., "origin/feature/my-work"). + * Undefined if branch is unpublished or no remote. + */ + remoteTrackingBranch?: string; + + /** + * Default remote branch used as base for unpublished branches (e.g., "origin/main"). + * Helpful for computing merge-base when branch has no tracking. + */ + remoteBaseBranch?: string; + + /** + * Commit hash of the remote tracking branch HEAD. + * Undefined if branch has no remote tracking branch. + */ + remoteHeadCommit?: string; + + /** + * Name of the current local branch (e.g., "feature/my-work"). + */ + localBranch?: string; + + /** + * Commit hash of the local HEAD when captured. + */ + localHeadCommit?: string; + + /** + * Working tree diffs (uncommitted changes). + */ + diffs?: IExportableRepoDiff[]; + + /** + * Status of the diffs collection. + * - `included`: Diffs were successfully captured and included + * - `tooManyChanges`: Diffs skipped because >100 files changed (degenerate case like mass renames) + * - `tooLarge`: Diffs skipped because total size exceeded 900KB + * - `trimmedForStorage`: Diffs were trimmed to save storage (older session) + * - `noChanges`: No working tree changes detected + * - `notCaptured`: Diffs not captured (default/undefined case) + */ + diffsStatus?: 'included' | 'tooManyChanges' | 'tooLarge' | 'trimmedForStorage' | 'noChanges' | 'notCaptured'; + + /** + * Number of changed files detected, even if diffs were not included. + */ + changedFileCount?: number; +} + +/** + * A file change exported as a unified diff patch compatible with `git apply`. + */ +export interface IExportableRepoDiff { + relativePath: string; + changeType: 'added' | 'modified' | 'deleted' | 'renamed'; + oldRelativePath?: string; + unifiedDiff?: string; + status: string; +} + export interface IExportableChatData { initialLocation: ChatAgentLocation | undefined; requests: ISerializableChatRequestData[]; @@ -1327,8 +1426,14 @@ export interface ISerializableChatData2 extends ISerializableChatData1 { export interface ISerializableChatData3 extends Omit { version: 3; customTitle: string | undefined; + /** + * Whether the session had pending edits when it was stored. + * todo@connor4312 This will be cleaned up with the globalization of edits. + */ + hasPendingEdits?: boolean; /** Current draft input state (added later, fully backwards compatible) */ inputState?: ISerializableChatModelInputState; + repoData?: IExportableRepoData; } /** @@ -1652,6 +1757,15 @@ export class ChatModel extends Disposable implements IChatModel { public setContributedChatSession(session: IChatSessionContext | undefined) { this._contributedChatSession = session; } + + private _repoData: IExportableRepoData | undefined; + public get repoData(): IExportableRepoData | undefined { + return this._repoData; + } + public setRepoData(data: IExportableRepoData | undefined): void { + this._repoData = data; + } + readonly lastRequestObs: IObservable; // TODO to be clear, this is not the same as the id from the session object, which belongs to the provider. @@ -1687,14 +1801,10 @@ export class ChatModel extends Disposable implements IChatModel { } get timing(): IChatSessionTiming { - const lastRequest = this._requests.at(-1); - const lastResponse = lastRequest?.response; - const lastRequestStarted = lastRequest?.timestamp; - const lastRequestEnded = lastResponse?.completedAt ?? lastResponse?.timestamp; + const lastResponse = this._requests.at(-1)?.response; return { - created: this._timestamp, - lastRequestStarted, - lastRequestEnded, + startTime: this._timestamp, + endTime: lastResponse?.completedAt ?? lastResponse?.timestamp }; } @@ -1795,6 +1905,9 @@ export class ChatModel extends Disposable implements IChatModel { this.dataSerializer = dataRef?.serializer; this._initialResponderUsername = initialData?.responderUsername; + + this._repoData = isValidFullData && initialData.repoData ? initialData.repoData : undefined; + this._initialLocation = initialData?.initialLocation ?? initialModelProps.initialLocation; this._canUseTools = initialModelProps.canUseTools; @@ -2241,6 +2354,7 @@ export class ChatModel extends Disposable implements IChatModel { creationDate: this._timestamp, customTitle: this._customTitle, inputState: this.inputModel.toJSON(), + repoData: this._repoData, }; } diff --git a/src/vs/workbench/contrib/chat/common/model/chatModelStore.ts b/src/vs/workbench/contrib/chat/common/model/chatModelStore.ts index 25b37e97ae82e..42305065ed589 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModelStore.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModelStore.ts @@ -38,6 +38,9 @@ export class ChatModelStore extends ReferenceCollection implements ID private readonly _onDidDisposeModel = this._store.add(new Emitter()); public readonly onDidDisposeModel = this._onDidDisposeModel.event; + private readonly _onDidCreateModel = this._store.add(new Emitter()); + public readonly onDidCreateModel = this._onDidCreateModel.event; + constructor( private readonly delegate: ChatModelStoreDelegate, @ILogService private readonly logService: ILogService, @@ -93,6 +96,7 @@ export class ChatModelStore extends ReferenceCollection implements ID throw new Error(`Chat session key mismatch for ${key}`); } this._models.set(key, model); + this._onDidCreateModel.fire(model); return model; } diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts index 97dda654be065..df3b644bb7420 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts @@ -8,6 +8,7 @@ import { isMarkdownString } from '../../../../../base/common/htmlContent.js'; import { equals as objectsEqual } from '../../../../../base/common/objects.js'; import { isEqual as urisEqual } from '../../../../../base/common/resources.js'; import { hasKey } from '../../../../../base/common/types.js'; +import { ModifiedFileEntryState } from '../editing/chatEditingService.js'; import { IChatMarkdownContent, ResponseModelState } from '../chatService/chatService.js'; import { IParsedChatRequest } from '../requestParser/chatParserTypes.js'; import { IChatAgentEditedFileEvent, IChatDataSerializerLog, IChatModel, IChatProgressResponseContent, IChatRequestModel, IChatRequestVariableData, ISerializableChatData, ISerializableChatModelInputState, ISerializableChatRequestData, SerializedChatResponsePart } from './chatModel.js'; @@ -161,6 +162,8 @@ export const storageSchema = Adapt.object({ responderUsername: Adapt.v(m => m.responderUsername), sessionId: Adapt.v(m => m.sessionId), requests: Adapt.t(m => m.getRequests(), Adapt.array(requestSchema)), + hasPendingEdits: Adapt.v(m => m.editingSession?.entries.get().some(e => e.state.get() === ModifiedFileEntryState.Modified)), + repoData: Adapt.v(m => m.repoData, objectsEqual), }); export class ChatSessionOperationLog extends Adapt.ObjectMutationLog implements IChatDataSerializerLog { diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts index 1465a8d5c5465..63ac4c99c214b 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts @@ -665,13 +665,12 @@ async function getSessionMetadata(session: ChatModel | ISerializableChatData): P session.lastMessageDate : session.requests.at(-1)?.timestamp ?? session.creationDate; - const timing: IChatSessionTiming = session instanceof ChatModel ? + const timing = session instanceof ChatModel ? session.timing : // session is only ISerializableChatData in the old pre-fs storage data migration scenario { - created: session.creationDate, - lastRequestStarted: session.requests.at(-1)?.timestamp, - lastRequestEnded: lastMessageDate, + startTime: session.creationDate, + endTime: lastMessageDate }; return { diff --git a/src/vs/workbench/contrib/chat/electron-browser/actions/chatExportZip.ts b/src/vs/workbench/contrib/chat/electron-browser/actions/chatExportZip.ts new file mode 100644 index 0000000000000..e16e8af4d8b2f --- /dev/null +++ b/src/vs/workbench/contrib/chat/electron-browser/actions/chatExportZip.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { joinPath } from '../../../../../base/common/resources.js'; +import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; +import { localize, localize2 } from '../../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { INativeHostService } from '../../../../../platform/native/common/native.js'; +import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; +import { ChatEntitlementContextKeys } from '../../../../services/chat/common/chatEntitlementService.js'; +import { CHAT_CATEGORY } from '../../browser/actions/chatActions.js'; +import { IChatWidgetService } from '../../browser/chat.js'; +import { captureRepoInfo } from '../../browser/chatRepoInfo.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { IChatService } from '../../common/chatService/chatService.js'; +import { ChatConfiguration } from '../../common/constants.js'; +import { ISCMService } from '../../../scm/common/scm.js'; + +export function registerChatExportZipAction() { + registerAction2(class ExportChatAsZipAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.exportAsZip', + category: CHAT_CATEGORY, + title: localize2('chat.exportAsZip.label', "Export Chat as Zip..."), + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatEntitlementContextKeys.Entitlement.internal), + f1: true, + }); + } + + async run(accessor: ServicesAccessor) { + const widgetService = accessor.get(IChatWidgetService); + const fileDialogService = accessor.get(IFileDialogService); + const chatService = accessor.get(IChatService); + const nativeHostService = accessor.get(INativeHostService); + const notificationService = accessor.get(INotificationService); + const scmService = accessor.get(ISCMService); + const fileService = accessor.get(IFileService); + const configurationService = accessor.get(IConfigurationService); + + const repoInfoEnabled = configurationService.getValue(ChatConfiguration.RepoInfoEnabled) ?? true; + + const widget = widgetService.lastFocusedWidget; + if (!widget || !widget.viewModel) { + return; + } + + const defaultUri = joinPath(await fileDialogService.defaultFilePath(), 'chat.zip'); + const result = await fileDialogService.showSaveDialog({ + defaultUri, + filters: [{ name: 'Zip Archive', extensions: ['zip'] }] + }); + + if (!result) { + return; + } + + const model = chatService.getSession(widget.viewModel.sessionResource); + if (!model) { + return; + } + + const files: { path: string; contents: string }[] = [ + { + path: 'chat.json', + contents: JSON.stringify(model.toExport(), undefined, 2) + } + ]; + + const hasMessages = model.getRequests().length > 0; + + if (hasMessages) { + if (model.repoData) { + files.push({ + path: 'chat.repo.begin.json', + contents: JSON.stringify(model.repoData, undefined, 2) + }); + } + + if (repoInfoEnabled) { + const currentRepoData = await captureRepoInfo(scmService, fileService); + if (currentRepoData) { + files.push({ + path: 'chat.repo.end.json', + contents: JSON.stringify(currentRepoData, undefined, 2) + }); + } + + if (!model.repoData && !currentRepoData) { + notificationService.notify({ + severity: Severity.Warning, + message: localize('chatExportZip.noRepoData', "Exported chat without repository context. No Git repository was detected.") + }); + } + } + } else { + if (repoInfoEnabled) { + const currentRepoData = await captureRepoInfo(scmService, fileService); + if (currentRepoData) { + files.push({ + path: 'chat.repo.begin.json', + contents: JSON.stringify(currentRepoData, undefined, 2) + }); + } else { + notificationService.notify({ + severity: Severity.Warning, + message: localize('chatExportZip.noRepoData', "Exported chat without repository context. No Git repository was detected.") + }); + } + } + } + + try { + await nativeHostService.createZipFile(result, files); + } catch (error) { + notificationService.notify({ + severity: Severity.Error, + message: localize('chatExportZip.error', "Failed to export chat as zip: {0}", error instanceof Error ? error.message : String(error)) + }); + } + } + }); +} diff --git a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts index 4ab40ef6a76c1..e4c7c9cfbab0e 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts @@ -30,6 +30,7 @@ import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; import { ChatConfiguration, ChatModeKind } from '../common/constants.js'; import { IChatService } from '../common/chatService/chatService.js'; import { registerChatDeveloperActions } from './actions/chatDeveloperActions.js'; +import { registerChatExportZipAction } from './actions/chatExportZip.js'; import { HoldToVoiceChatInChatViewAction, InlineVoiceChatAction, KeywordActivationContribution, QuickVoiceChatAction, ReadChatResponseAloud, StartVoiceChatAction, StopListeningAction, StopListeningAndSubmitAction, StopReadAloud, StopReadChatItemAloud, VoiceChatInChatViewAction } from './actions/voiceChatActions.js'; import { NativeBuiltinToolsContribution } from './builtInTools/tools.js'; @@ -200,6 +201,7 @@ registerAction2(StopReadChatItemAloud); registerAction2(StopReadAloud); registerChatDeveloperActions(); +registerChatExportZipAction(); registerWorkbenchContribution2(KeywordActivationContribution.ID, KeywordActivationContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(NativeBuiltinToolsContribution.ID, NativeBuiltinToolsContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts index bacf032abd9b5..114f666d13545 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts @@ -176,8 +176,8 @@ suite('Agent Sessions', () => { test('should handle session with all properties', async () => { return runWithFakedTimers({}, async () => { - const created = Date.now(); - const lastRequestEnded = created + 1000; + const startTime = Date.now(); + const endTime = startTime + 1000; const provider: IChatSessionItemProvider = { chatSessionType: 'test-type', @@ -190,8 +190,8 @@ suite('Agent Sessions', () => { status: ChatSessionStatus.Completed, tooltip: 'Session tooltip', iconPath: ThemeIcon.fromId('check'), - timing: { created, lastRequestStarted: created, lastRequestEnded }, - changes: { files: 1, insertions: 10, deletions: 5 } + timing: { startTime, endTime }, + changes: { files: 1, insertions: 10, deletions: 5, details: [] } } ] }; @@ -210,8 +210,8 @@ suite('Agent Sessions', () => { assert.strictEqual(session.description.value, '**Bold** description'); } assert.strictEqual(session.status, ChatSessionStatus.Completed); - assert.strictEqual(session.timing.created, created); - assert.strictEqual(session.timing.lastRequestEnded, lastRequestEnded); + assert.strictEqual(session.timing.startTime, startTime); + assert.strictEqual(session.timing.endTime, endTime); assert.deepStrictEqual(session.changes, { files: 1, insertions: 10, deletions: 5 }); }); }); @@ -1521,10 +1521,9 @@ suite('Agent Sessions', () => { test('should consider sessions before initial date as read by default', async () => { return runWithFakedTimers({}, async () => { // Session with timing before the READ_STATE_INITIAL_DATE (December 8, 2025) - const oldSessionTiming: IChatSessionItem['timing'] = { - created: Date.UTC(2025, 10 /* November */, 1), - lastRequestStarted: Date.UTC(2025, 10 /* November */, 1), - lastRequestEnded: Date.UTC(2025, 10 /* November */, 2), + const oldSessionTiming = { + startTime: Date.UTC(2025, 10 /* November */, 1), + endTime: Date.UTC(2025, 10 /* November */, 2), }; const provider: IChatSessionItemProvider = { @@ -1553,10 +1552,9 @@ suite('Agent Sessions', () => { test('should consider sessions after initial date as unread by default', async () => { return runWithFakedTimers({}, async () => { // Session with timing after the READ_STATE_INITIAL_DATE (December 8, 2025) - const newSessionTiming: IChatSessionItem['timing'] = { - created: Date.UTC(2025, 11 /* December */, 10), - lastRequestStarted: Date.UTC(2025, 11 /* December */, 10), - lastRequestEnded: Date.UTC(2025, 11 /* December */, 11), + const newSessionTiming = { + startTime: Date.UTC(2025, 11 /* December */, 10), + endTime: Date.UTC(2025, 11 /* December */, 11), }; const provider: IChatSessionItemProvider = { @@ -1585,10 +1583,9 @@ suite('Agent Sessions', () => { test('should use endTime for read state comparison when available', async () => { return runWithFakedTimers({}, async () => { // Session with startTime before initial date but endTime after - const sessionTiming: IChatSessionItem['timing'] = { - created: Date.UTC(2025, 10 /* November */, 1), - lastRequestStarted: Date.UTC(2025, 10 /* November */, 1), - lastRequestEnded: Date.UTC(2025, 11 /* December */, 10), + const sessionTiming = { + startTime: Date.UTC(2025, 10 /* November */, 1), + endTime: Date.UTC(2025, 11 /* December */, 10), }; const provider: IChatSessionItemProvider = { @@ -1609,7 +1606,7 @@ suite('Agent Sessions', () => { await viewModel.resolve(undefined); const session = viewModel.sessions[0]; - // Should use lastRequestEnded (December 10) which is after the initial date + // Should use endTime (December 10) which is after the initial date assert.strictEqual(session.isRead(), false); }); }); @@ -1617,10 +1614,8 @@ suite('Agent Sessions', () => { test('should use startTime for read state comparison when endTime is not available', async () => { return runWithFakedTimers({}, async () => { // Session with only startTime before initial date - const sessionTiming: IChatSessionItem['timing'] = { - created: Date.UTC(2025, 10 /* November */, 1), - lastRequestStarted: Date.UTC(2025, 10 /* November */, 1), - lastRequestEnded: undefined, + const sessionTiming = { + startTime: Date.UTC(2025, 10 /* November */, 1), }; const provider: IChatSessionItemProvider = { @@ -2059,15 +2054,8 @@ function makeSimpleSessionItem(id: string, overrides?: Partial }; } -function makeNewSessionTiming(options?: { - created?: number; - lastRequestStarted?: number | undefined; - lastRequestEnded?: number | undefined; -}): IChatSessionItem['timing'] { - const now = Date.now(); +function makeNewSessionTiming(): IChatSessionItem['timing'] { return { - created: options?.created ?? now, - lastRequestStarted: options?.lastRequestStarted, - lastRequestEnded: options?.lastRequestEnded, + startTime: Date.now(), }; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts index d551277757baf..f29f8f83327e5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts @@ -36,9 +36,8 @@ suite('AgentSessionsDataSource', () => { label: `Session ${overrides.id ?? 'default'}`, icon: Codicon.terminal, timing: { - created: overrides.startTime ?? now, - lastRequestEnded: undefined, - lastRequestStarted: undefined, + startTime: overrides.startTime ?? now, + endTime: overrides.endTime ?? now, }, isArchived: () => overrides.isArchived ?? false, setArchived: () => { }, @@ -74,8 +73,8 @@ suite('AgentSessionsDataSource', () => { return { compare: (a, b) => { // Sort by end time, most recent first - const aTime = a.timing.lastRequestEnded ?? a.timing.lastRequestStarted ?? a.timing.created; - const bTime = b.timing.lastRequestEnded ?? b.timing.lastRequestStarted ?? b.timing.created; + const aTime = a.timing.endTime || a.timing.startTime; + const bTime = b.timing.endTime || b.timing.startTime; return bTime - aTime; } }; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts index ac5db0d49804d..7be0701efe294 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts @@ -18,24 +18,11 @@ import { LocalAgentsSessionsProvider } from '../../../browser/agentSessions/loca import { ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { IChatModel, IChatRequestModel, IChatResponseModel } from '../../../common/model/chatModel.js'; import { IChatDetail, IChatService, IChatSessionStartOptions, ResponseModelState } from '../../../common/chatService/chatService.js'; -import { ChatSessionStatus, IChatSessionItem, IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; +import { ChatSessionStatus, IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; import { LocalChatSessionUri } from '../../../common/model/chatUri.js'; import { ChatAgentLocation } from '../../../common/constants.js'; import { MockChatSessionsService } from '../../common/mockChatSessionsService.js'; -function createTestTiming(options?: { - created?: number; - lastRequestStarted?: number | undefined; - lastRequestEnded?: number | undefined; -}): IChatSessionItem['timing'] { - const now = Date.now(); - return { - created: options?.created ?? now, - lastRequestStarted: options?.lastRequestStarted, - lastRequestEnded: options?.lastRequestEnded, - }; -} - class MockChatService implements IChatService { private readonly _chatModels: ISettableObservable> = observableValue('chatModels', []); readonly chatModels = this._chatModels; @@ -45,6 +32,7 @@ class MockChatService implements IChatService { editingSessions = []; transferredSessionResource = undefined; readonly onDidSubmitRequest = Event.None; + readonly onDidCreateModel = Event.None; private sessions = new Map(); private liveSessionItems: IChatDetail[] = []; @@ -331,7 +319,7 @@ suite('LocalAgentsSessionsProvider', () => { title: 'Test Session', lastMessageDate: Date.now(), isActive: true, - timing: createTestTiming(), + timing: { startTime: 0, endTime: 1 }, lastResponseState: ResponseModelState.Complete }]); @@ -355,7 +343,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now() - 10000, isActive: false, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming() + timing: { startTime: 0, endTime: 1 } }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -381,7 +369,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming() + timing: { startTime: 0, endTime: 1 } }]); mockChatService.setHistorySessionItems([{ sessionResource, @@ -389,7 +377,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now() - 10000, isActive: false, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming() + timing: { startTime: 0, endTime: 1 } }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -417,7 +405,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming() + timing: { startTime: 0, endTime: 1 } }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -447,7 +435,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming(), + timing: { startTime: 0, endTime: 1 }, }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -476,7 +464,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming(), + timing: { startTime: 0, endTime: 1 }, }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -505,7 +493,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming(), + timing: { startTime: 0, endTime: 1 }, }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -549,7 +537,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming(), + timing: { startTime: 0, endTime: 1 }, stats: { added: 30, removed: 8, @@ -594,7 +582,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming() + timing: { startTime: 0, endTime: 1 } }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); @@ -605,7 +593,7 @@ suite('LocalAgentsSessionsProvider', () => { }); suite('Session Timing', () => { - test('should use model timestamp for created when model exists', async () => { + test('should use model timestamp for startTime when model exists', async () => { return runWithFakedTimers({}, async () => { const provider = createProvider(); @@ -624,16 +612,16 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming({ created: modelTimestamp }) + timing: { startTime: modelTimestamp } }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].timing.created, modelTimestamp); + assert.strictEqual(sessions[0].timing.startTime, modelTimestamp); }); }); - test('should use lastMessageDate for created when model does not exist', async () => { + test('should use lastMessageDate for startTime when model does not exist', async () => { return runWithFakedTimers({}, async () => { const provider = createProvider(); @@ -647,16 +635,16 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate, isActive: false, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming({ created: lastMessageDate }) + timing: { startTime: lastMessageDate } }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].timing.created, lastMessageDate); + assert.strictEqual(sessions[0].timing.startTime, lastMessageDate); }); }); - test('should set lastRequestEnded from last response completedAt', async () => { + test('should set endTime from last response completedAt', async () => { return runWithFakedTimers({}, async () => { const provider = createProvider(); @@ -676,12 +664,12 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming({ lastRequestEnded: completedAt }) + timing: { startTime: 0, endTime: completedAt } }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].timing.lastRequestEnded, completedAt); + assert.strictEqual(sessions[0].timing.endTime, completedAt); }); }); }); @@ -704,7 +692,7 @@ suite('LocalAgentsSessionsProvider', () => { lastMessageDate: Date.now(), isActive: true, lastResponseState: ResponseModelState.Complete, - timing: createTestTiming() + timing: { startTime: 0, endTime: 1 } }]); const sessions = await provider.provideChatSessionItems(CancellationToken.None); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts index a7a5cce8e4bd5..42610e5a4c7f7 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts @@ -21,6 +21,7 @@ export class MockChatService implements IChatService { editingSessions = []; transferredSessionResource: URI | undefined; readonly onDidSubmitRequest: Event<{ readonly chatSessionResource: URI }> = Event.None; + readonly onDidCreateModel: Event = Event.None; private sessions = new ResourceMap(); diff --git a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts index d9f5d6113d30f..4cced4a16c470 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts @@ -8,16 +8,15 @@ import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { IObservable, observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { IChatEditingSession } from '../../../common/editing/chatEditingService.js'; -import { IChatChangeEvent, IChatModel, IChatRequestModel, IChatRequestNeedsInputInfo, IExportableChatData, IInputModel, ISerializableChatData } from '../../../common/model/chatModel.js'; +import { IChatChangeEvent, IChatModel, IChatRequestModel, IChatRequestNeedsInputInfo, IExportableChatData, IExportableRepoData, IInputModel, ISerializableChatData } from '../../../common/model/chatModel.js'; import { ChatAgentLocation } from '../../../common/constants.js'; -import { IChatSessionTiming } from '../../../common/chatService/chatService.js'; export class MockChatModel extends Disposable implements IChatModel { readonly onDidDispose = this._register(new Emitter()).event; readonly onDidChange = this._register(new Emitter()).event; sessionId = ''; readonly timestamp = 0; - readonly timing: IChatSessionTiming = { created: Date.now(), lastRequestStarted: undefined, lastRequestEnded: undefined }; + readonly timing = { startTime: 0 }; readonly initialLocation = ChatAgentLocation.Chat; readonly title = ''; readonly hasCustomTitle = false; @@ -39,6 +38,7 @@ export class MockChatModel extends Disposable implements IChatModel { toJSON: () => undefined }; readonly contributedChatSession = undefined; + repoData: IExportableRepoData | undefined = undefined; isDisposed = false; lastRequestObs: IObservable; @@ -59,6 +59,7 @@ export class MockChatModel extends Disposable implements IChatModel { startEditingSession(isGlobalEditingSession?: boolean, transferFromSession?: IChatEditingSession): void { } getRequests(): IChatRequestModel[] { return []; } setCheckpoint(requestId: string | undefined): void { } + setRepoData(data: IExportableRepoData | undefined): void { this.repoData = data; } toExport(): IExportableChatData { return { initialLocation: this.initialLocation, @@ -75,6 +76,7 @@ export class MockChatModel extends Disposable implements IChatModel { initialLocation: this.initialLocation, requests: [], responderUsername: '', + repoData: this.repoData }; } } diff --git a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css index fcdb9a19960c7..e53c6ec761b4c 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css +++ b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css @@ -172,7 +172,7 @@ max-width: 66%; } -.monaco-workbench .inline-chat .chat-widget .interactive-session .chat-input-toolbars > .chat-execute-toolbar .chat-modelPicker-item { +.monaco-workbench .inline-chat .chat-widget .interactive-session .chat-input-toolbars > .chat-execute-toolbar .chat-input-picker-item { min-width: 40px; max-width: 132px; } diff --git a/src/vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateWorkerTokenizer.ts b/src/vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateWorkerTokenizer.ts index d410a975a99c7..6b5bc990d72aa 100644 --- a/src/vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateWorkerTokenizer.ts +++ b/src/vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateWorkerTokenizer.ts @@ -198,8 +198,8 @@ export class TextMateWorkerTokenizer extends MirrorTextModel { range: new OffsetRange(offsetAtLineStart + fontInfo.startIndex, offsetAtLineStart + fontInfo.endIndex), annotation: { fontFamily: fontInfo.fontFamily ?? undefined, - fontSize: fontInfo.fontSize ?? undefined, - lineHeight: fontInfo.lineHeight ?? undefined + fontSizeMultiplier: fontInfo.fontSizeMultiplier ?? undefined, + lineHeightMultiplier: fontInfo.lineHeightMultiplier ?? undefined } }); } diff --git a/src/vs/workbench/services/themes/common/colorThemeData.ts b/src/vs/workbench/services/themes/common/colorThemeData.ts index 386d668f89ce2..75c05cdec531a 100644 --- a/src/vs/workbench/services/themes/common/colorThemeData.ts +++ b/src/vs/workbench/services/themes/common/colorThemeData.ts @@ -1014,8 +1014,8 @@ class TokenFontIndex { this._font2id = new Map(); } - public add(fontFamily: string | undefined, fontSize: string | undefined, lineHeight: number | undefined): number { - const font: IFontTokenOptions = { fontFamily, fontSize, lineHeight }; + public add(fontFamily: string | undefined, fontSizeMultiplier: number | undefined, lineHeightMultiplier: number | undefined): number { + const font: IFontTokenOptions = { fontFamily, fontSizeMultiplier, lineHeightMultiplier }; let value = this._font2id.get(font); if (value) { return value; diff --git a/src/vs/workbench/services/themes/common/colorThemeSchema.ts b/src/vs/workbench/services/themes/common/colorThemeSchema.ts index ddcc9f57c095e..99ed142d4b765 100644 --- a/src/vs/workbench/services/themes/common/colorThemeSchema.ts +++ b/src/vs/workbench/services/themes/common/colorThemeSchema.ts @@ -175,12 +175,12 @@ const textmateColorSchema: IJSONSchema = { description: nls.localize('schema.token.fontFamily', 'Font family for the token (e.g., "Fira Code", "JetBrains Mono").') }, fontSize: { - type: 'string', - description: nls.localize('schema.token.fontSize', 'Font size string for the token (e.g., "14px", "1.2em").') + type: 'number', + description: nls.localize('schema.token.fontSize', 'Font size multiplier for the token (e.g., 1.2 will use 1.2 times the default font size).') }, lineHeight: { type: 'number', - description: nls.localize('schema.token.lineHeight', 'Line height number for the token (e.g., "20").') + description: nls.localize('schema.token.lineHeight', 'Line height multiplier for the token (e.g., 1.2 will use 1.2 times the default height).') } }, additionalProperties: false, diff --git a/src/vs/workbench/services/themes/common/workbenchThemeService.ts b/src/vs/workbench/services/themes/common/workbenchThemeService.ts index 679f93e938514..a214818b29ce2 100644 --- a/src/vs/workbench/services/themes/common/workbenchThemeService.ts +++ b/src/vs/workbench/services/themes/common/workbenchThemeService.ts @@ -478,7 +478,7 @@ export interface ITokenColorizationSetting { background?: string; fontStyle?: string; /* [italic|bold|underline|strikethrough] */ fontFamily?: string; - fontSize?: string; + fontSize?: number; lineHeight?: number; } diff --git a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts index 11ab6065d95f6..025bc77477e34 100644 --- a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts @@ -172,6 +172,7 @@ export class TestNativeHostService implements INativeHostService { async readClipboardBuffer(format: string): Promise { return VSBuffer.wrap(Uint8Array.from([])); } async hasClipboard(format: string, type?: 'selection' | 'clipboard' | undefined): Promise { return false; } async windowsGetStringRegKey(hive: 'HKEY_CURRENT_USER' | 'HKEY_LOCAL_MACHINE' | 'HKEY_CLASSES_ROOT' | 'HKEY_USERS' | 'HKEY_CURRENT_CONFIG', path: string, name: string): Promise { return undefined; } + async createZipFile(zipPath: URI, files: { path: string; contents: string }[]): Promise { } async profileRenderer(): Promise { throw new Error(); } async getScreenshot(rect?: IRectangle): Promise { return undefined; } } diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index c1cbdf9c7157f..ac6ade0f4137b 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 4 +// version: 3 declare module 'vscode' { /** @@ -26,25 +26,6 @@ declare module 'vscode' { InProgress = 2 } - export namespace chat { - /** - * Registers a new {@link ChatSessionItemProvider chat session item provider}. - * - * To use this, also make sure to also add `chatSessions` contribution in the `package.json`. - * - * @param chatSessionType The type of chat session the provider is for. - * @param provider The provider to register. - * - * @returns A disposable that unregisters the provider when disposed. - */ - export function registerChatSessionItemProvider(chatSessionType: string, provider: ChatSessionItemProvider): Disposable; - - /** - * Creates a new {@link ChatSessionItemController chat session item controller} with the given unique identifier. - */ - export function createChatSessionItemController(id: string, refreshHandler: () => Thenable): ChatSessionItemController; - } - /** * Provides a list of information about chat sessions. */ @@ -71,86 +52,6 @@ declare module 'vscode' { // #endregion } - /** - * Provides a list of information about chat sessions. - */ - export interface ChatSessionItemController { - readonly id: string; - - /** - * Unregisters the controller, disposing of its associated chat session items. - */ - dispose(): void; - - /** - * Managed collection of chat session items - */ - readonly items: ChatSessionItemCollection; - - /** - * Creates a new managed chat session item that be added to the collection. - */ - createChatSessionItem(resource: Uri, label: string): ChatSessionItem; - - /** - * Handler called to refresh the collection of chat session items. - * - * This is also called on first load to get the initial set of items. - */ - refreshHandler: () => Thenable; - - /** - * Fired when an item is archived by the editor - * - * TODO: expose archive state on the item too? - */ - readonly onDidArchiveChatSessionItem: Event; - } - - /** - * A collection of chat session items. It provides operations for managing and iterating over the items. - */ - export interface ChatSessionItemCollection extends Iterable { - /** - * Gets the number of items in the collection. - */ - readonly size: number; - - /** - * Replaces the items stored by the collection. - * @param items Items to store. - */ - replace(items: readonly ChatSessionItem[]): void; - - /** - * Iterate over each entry in this collection. - * - * @param callback Function to execute for each entry. - * @param thisArg The `this` context used when invoking the handler function. - */ - forEach(callback: (item: ChatSessionItem, collection: ChatSessionItemCollection) => unknown, thisArg?: any): void; - - /** - * Adds the chat session item to the collection. If an item with the same resource URI already - * exists, it'll be replaced. - * @param item Item to add. - */ - add(item: ChatSessionItem): void; - - /** - * Removes a single chat session item from the collection. - * @param resource Item resource to delete. - */ - delete(resource: Uri): void; - - /** - * Efficiently gets a chat session item by resource, if it exists, in the collection. - * @param resource Item resource to get. - * @returns The found item or undefined if it does not exist. - */ - get(resource: Uri): ChatSessionItem | undefined; - } - export interface ChatSessionItem { /** * The resource associated with the chat session. @@ -190,42 +91,15 @@ declare module 'vscode' { tooltip?: string | MarkdownString; /** - * Whether the chat session has been archived. - */ - archived?: boolean; - - /** - * Timing information for the chat session + * The times at which session started and ended */ timing?: { - /** - * Timestamp when the session was created in milliseconds elapsed since January 1, 1970 00:00:00 UTC. - */ - created: number; - - /** - * Timestamp when the most recent request started in milliseconds elapsed since January 1, 1970 00:00:00 UTC. - * - * Should be undefined if no requests have been made yet. - */ - lastRequestStarted?: number; - - /** - * Timestamp when the most recent request completed in milliseconds elapsed since January 1, 1970 00:00:00 UTC. - * - * Should be undefined if the most recent request is still in progress or if no requests have been made yet. - */ - lastRequestEnded?: number; - /** * Session start timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. - * @deprecated Use `created` and `lastRequestStarted` instead. */ - startTime?: number; - + startTime: number; /** * Session end timestamp in milliseconds elapsed since January 1, 1970 00:00:00 UTC. - * @deprecated Use `lastRequestEnded` instead. */ endTime?: number; }; @@ -394,6 +268,18 @@ declare module 'vscode' { } export namespace chat { + /** + * Registers a new {@link ChatSessionItemProvider chat session item provider}. + * + * To use this, also make sure to also add `chatSessions` contribution in the `package.json`. + * + * @param chatSessionType The type of chat session the provider is for. + * @param provider The provider to register. + * + * @returns A disposable that unregisters the provider when disposed. + */ + export function registerChatSessionItemProvider(chatSessionType: string, provider: ChatSessionItemProvider): Disposable; + /** * Registers a new {@link ChatSessionContentProvider chat session content provider}. * diff --git a/test/automation/src/application.ts b/test/automation/src/application.ts index 848640a49839e..2a68759388fb5 100644 --- a/test/automation/src/application.ts +++ b/test/automation/src/application.ts @@ -50,7 +50,10 @@ export class Application { } private _workspacePathOrFolder: string | undefined; - get workspacePathOrFolder(): string | undefined { + get workspacePathOrFolder(): string { + if (!this._workspacePathOrFolder) { + throw new Error('This test requires a workspace to be open'); + } return this._workspacePathOrFolder; } @@ -78,7 +81,7 @@ export class Application { })(), 'Application#restart()', this.logger); } - private async _start(workspaceOrFolder = this.workspacePathOrFolder, extraArgs: string[] = []): Promise { + private async _start(workspaceOrFolder = this._workspacePathOrFolder, extraArgs: string[] = []): Promise { this._workspacePathOrFolder = workspaceOrFolder; // Launch Code... diff --git a/test/smoke/src/areas/languages/languages.test.ts b/test/smoke/src/areas/languages/languages.test.ts index 508a35d9d4d61..3db5c7c98949a 100644 --- a/test/smoke/src/areas/languages/languages.test.ts +++ b/test/smoke/src/areas/languages/languages.test.ts @@ -15,12 +15,8 @@ export function setup(logger: Logger) { it('verifies quick outline (js)', async function () { const app = this.app as Application; - const workspacePathOrFolder = app.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } - await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'bin', 'www')); + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'bin', 'www')); await app.workbench.quickaccess.openQuickOutline(); await app.workbench.quickinput.waitForQuickInputElements(names => names.length >= 6); @@ -29,12 +25,8 @@ export function setup(logger: Logger) { it('verifies quick outline (css)', async function () { const app = this.app as Application; - const workspacePathOrFolder = app.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } - await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); await app.workbench.quickaccess.openQuickOutline(); await app.workbench.quickinput.waitForQuickInputElements(names => names.length === 2); @@ -43,12 +35,8 @@ export function setup(logger: Logger) { it('verifies problems view (css)', async function () { const app = this.app as Application; - const workspacePathOrFolder = app.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } - await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); await app.workbench.editor.waitForTypeInEditor('style.css', '.foo{}'); await app.code.waitForElement(Problems.getSelectorInEditor(ProblemSeverity.WARNING)); @@ -60,13 +48,9 @@ export function setup(logger: Logger) { it('verifies settings (css)', async function () { const app = this.app as Application; - const workspacePathOrFolder = app.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } await app.workbench.settingsEditor.addUserSetting('css.lint.emptyRules', '"error"'); - await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'public', 'stylesheets', 'style.css')); await app.code.waitForElement(Problems.getSelectorInEditor(ProblemSeverity.ERROR)); diff --git a/test/smoke/src/areas/notebook/notebook.test.ts b/test/smoke/src/areas/notebook/notebook.test.ts index b104ce26f76ff..39fc1e339f51d 100644 --- a/test/smoke/src/areas/notebook/notebook.test.ts +++ b/test/smoke/src/areas/notebook/notebook.test.ts @@ -21,13 +21,9 @@ export function setup(logger: Logger) { after(async function () { const app = this.app as Application; - const workspacePathOrFolder = app.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } - cp.execSync('git checkout . --quiet', { cwd: workspacePathOrFolder }); - cp.execSync('git reset --hard HEAD --quiet', { cwd: workspacePathOrFolder }); + cp.execSync('git checkout . --quiet', { cwd: app.workspacePathOrFolder }); + cp.execSync('git reset --hard HEAD --quiet', { cwd: app.workspacePathOrFolder }); }); // the heap snapshot fails to parse diff --git a/test/smoke/src/areas/search/search.test.ts b/test/smoke/src/areas/search/search.test.ts index 8ac0bba570f69..40db1cb07c0c2 100644 --- a/test/smoke/src/areas/search/search.test.ts +++ b/test/smoke/src/areas/search/search.test.ts @@ -15,13 +15,9 @@ export function setup(logger: Logger) { after(function () { const app = this.app as Application; - const workspacePathOrFolder = app.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } - retry(async () => cp.execSync('git checkout . --quiet', { cwd: workspacePathOrFolder }), 0, 5); - retry(async () => cp.execSync('git reset --hard HEAD --quiet', { cwd: workspacePathOrFolder }), 0, 5); + retry(async () => cp.execSync('git checkout . --quiet', { cwd: app.workspacePathOrFolder }), 0, 5); + retry(async () => cp.execSync('git reset --hard HEAD --quiet', { cwd: app.workspacePathOrFolder }), 0, 5); }); it('verifies the sidebar moves to the right', async function () { diff --git a/test/smoke/src/areas/statusbar/statusbar.test.ts b/test/smoke/src/areas/statusbar/statusbar.test.ts index f681758562ec9..edf594ad7e919 100644 --- a/test/smoke/src/areas/statusbar/statusbar.test.ts +++ b/test/smoke/src/areas/statusbar/statusbar.test.ts @@ -15,16 +15,12 @@ export function setup(logger: Logger) { it('verifies presence of all default status bar elements', async function () { const app = this.app as Application; - const workspacePathOrFolder = app.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.BRANCH_STATUS); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.SYNC_STATUS); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.PROBLEMS_STATUS); - await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'readme.md')); + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'readme.md')); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.ENCODING_STATUS); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.EOL_STATUS); await app.workbench.statusbar.waitForStatusbarElement(StatusBarElement.INDENTATION_STATUS); @@ -34,16 +30,12 @@ export function setup(logger: Logger) { it(`verifies that 'quick input' opens when clicking on status bar elements`, async function () { const app = this.app as Application; - const workspacePathOrFolder = app.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } await app.workbench.statusbar.clickOn(StatusBarElement.BRANCH_STATUS); await app.workbench.quickinput.waitForQuickInputOpened(); await app.workbench.quickinput.closeQuickInput(); - await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'readme.md')); + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'readme.md')); await app.workbench.statusbar.clickOn(StatusBarElement.INDENTATION_STATUS); await app.workbench.quickinput.waitForQuickInputOpened(); await app.workbench.quickinput.closeQuickInput(); @@ -66,12 +58,8 @@ export function setup(logger: Logger) { it(`verifies if changing EOL is reflected in the status bar`, async function () { const app = this.app as Application; - const workspacePathOrFolder = app.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } - await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'readme.md')); + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'readme.md')); await app.workbench.statusbar.clickOn(StatusBarElement.EOL_STATUS); await app.workbench.quickinput.selectQuickInputElement(1); diff --git a/test/smoke/src/areas/workbench/data-loss.test.ts b/test/smoke/src/areas/workbench/data-loss.test.ts index f876f8596bdfd..3fb16b61c745a 100644 --- a/test/smoke/src/areas/workbench/data-loss.test.ts +++ b/test/smoke/src/areas/workbench/data-loss.test.ts @@ -27,15 +27,11 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin }); await app.start(); - const workspacePathOrFolder = app.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } // Open 3 editors - await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'bin', 'www')); + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'bin', 'www')); await app.workbench.quickaccess.runCommand('View: Keep Editor'); - await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'app.js')); + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'app.js')); await app.workbench.quickaccess.runCommand('View: Keep Editor'); await app.workbench.editors.newUntitledFile(); @@ -58,15 +54,10 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin }); await app.start(); - const workspacePathOrFolder = app.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } - const textToType = 'Hello, Code'; // open editor and type - await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'app.js')); + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'app.js')); await app.workbench.editor.waitForTypeInEditor('app.js', textToType); await app.workbench.editors.waitForTab('app.js', true); @@ -104,11 +95,6 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin }); await app.start(); - const workspacePathOrFolder = app.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } - if (autoSave) { await app.workbench.settingsEditor.addUserSetting('files.autoSave', '"afterDelay"'); } @@ -120,7 +106,7 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin await app.workbench.editors.waitForTab('Untitled-1', true); const textToType = 'Hello, Code'; - await app.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'readme.md')); + await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'readme.md')); await app.workbench.editor.waitForTypeInEditor('readme.md', textToType); await app.workbench.editors.waitForTab('readme.md', !autoSave); @@ -190,15 +176,10 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin stableApp = new Application(stableOptions); await stableApp.start(); - const workspacePathOrFolder = stableApp.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } - // Open 3 editors - await stableApp.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'bin', 'www')); + await stableApp.workbench.quickaccess.openFile(join(stableApp.workspacePathOrFolder, 'bin', 'www')); await stableApp.workbench.quickaccess.runCommand('View: Keep Editor'); - await stableApp.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'app.js')); + await stableApp.workbench.quickaccess.openFile(join(stableApp.workspacePathOrFolder, 'app.js')); await stableApp.workbench.quickaccess.runCommand('View: Keep Editor'); await stableApp.workbench.editors.newUntitledFile(); @@ -251,11 +232,6 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin stableApp = new Application(stableOptions); await stableApp.start(); - const workspacePathOrFolder = stableApp.workspacePathOrFolder; - if (!workspacePathOrFolder) { - throw new Error('This test requires a workspace to be open'); - } - const textToTypeInUntitled = 'Hello from Untitled'; await stableApp.workbench.editors.newUntitledFile(); @@ -263,7 +239,7 @@ export function setup(ensureStableCode: () => { stableCodePath: string | undefin await stableApp.workbench.editors.waitForTab('Untitled-1', true); const textToType = 'Hello, Code'; - await stableApp.workbench.quickaccess.openFile(join(workspacePathOrFolder, 'readme.md')); + await stableApp.workbench.quickaccess.openFile(join(stableApp.workspacePathOrFolder, 'readme.md')); await stableApp.workbench.editor.waitForTypeInEditor('readme.md', textToType); await stableApp.workbench.editors.waitForTab('readme.md', true);