diff --git a/.github/skills/chat-customizations-editor/SKILL.md b/.github/skills/chat-customizations-editor/SKILL.md index 8d4ce8edda10a..ed3cf72b74193 100644 --- a/.github/skills/chat-customizations-editor/SKILL.md +++ b/.github/skills/chat-customizations-editor/SKILL.md @@ -27,9 +27,24 @@ When changing harness descriptor interfaces or factory functions, verify both co - **`IHarnessDescriptor`** — drives all UI behavior declaratively (hidden sections, button overrides, file filters, agent gating). See spec for full field reference. - **`ISectionOverride`** — per-section button customization (command invocation, root file creation, type labels, file extensions). - **`IStorageSourceFilter`** — controls which storage sources and user roots are visible per harness/type. +- **`IExternalCustomizationItemProvider`** / **`IExternalCustomizationItem`** — internal interfaces (in `customizationHarnessService.ts`) for extension-contributed providers that supply items directly. These mirror the proposed extension API types. Principle: the UI widgets read everything from the descriptor — no harness-specific conditionals in widget code. +## Extension API (`chatSessionCustomizationProvider`) + +The proposed API in `src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts` lets extensions register customization providers. Changes to `IExternalCustomizationItem` or `IExternalCustomizationItemProvider` must be kept in sync across the full chain: + +| Layer | File | Type | +|-------|------|------| +| Extension API | `vscode.proposed.chatSessionCustomizationProvider.d.ts` | `ChatSessionCustomizationItem` | +| IPC DTO | `extHost.protocol.ts` | `IChatSessionCustomizationItemDto` | +| ExtHost mapping | `extHostChatAgents2.ts` | `$provideChatSessionCustomizations()` | +| MainThread mapping | `mainThreadChatAgents2.ts` | `provideChatSessionCustomizations` callback | +| Internal interface | `customizationHarnessService.ts` | `IExternalCustomizationItem` | + +When adding fields to `IExternalCustomizationItem`, update all five layers. The proposed API `.d.ts` is additive-only (new optional fields are backward-compatible and do not require a version bump). + ## Testing Component explorer fixtures (see `component-fixtures` skill): `aiCustomizationListWidget.fixture.ts`, `aiCustomizationManagementEditor.fixture.ts` under `src/vs/workbench/test/browser/componentFixtures/`. diff --git a/.github/workflows/no-engineering-system-changes.yml b/.github/workflows/no-engineering-system-changes.yml index be9cf34d0777a..954d8dbf0c504 100644 --- a/.github/workflows/no-engineering-system-changes.yml +++ b/.github/workflows/no-engineering-system-changes.yml @@ -57,7 +57,7 @@ jobs: echo "Copilot is not allowed to modify .github/workflows, build folder files, or package.json files." echo "If you need to update engineering systems, please do so manually or through authorized means." exit 1 - - uses: octokit/request-action@dad4362715b7fb2ddedf9772c8670824af564f0d # v2.4.0 + - uses: octokit/request-action@b91aabaa861c777dcdb14e2387e30eddf04619ae # v3.0.0 id: get_permissions if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && steps.distro_exception.outputs.allowed != 'true' && github.event.pull_request.user.login != 'Copilot' }} with: diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index 56c24729257da..da3c09215cfb1 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -1369,16 +1369,47 @@ export class Repository implements Disposable { await this.run( Operation.Restore(!this.optimisticUpdateEnabled()), async () => { - const resourcePaths = resources.map(r => r.fsPath); - await this.repository.restore(resourcePaths, options); + const toClean: string[] = []; + const toRestore: string[] = []; - if (options?.staged) { - // Index was modified; - this.closeDiffEditors([], resourcePaths); - } else { - // Working tree was modified; - this.closeDiffEditors(resourcePaths, []); + const resourceStates = [ + ...this.indexGroup.resourceStates, + ...this.workingTreeGroup.resourceStates, + ...this.untrackedGroup.resourceStates + ]; + + for (const resource of resources) { + const scmResource = find(resourceStates, r => r.resourceUri.toString() === resource.toString()); + + if (!scmResource) { + toRestore.push(resource.fsPath); + continue; + } + + switch (scmResource.type) { + case Status.UNTRACKED: + case Status.IGNORED: + toClean.push(resource.fsPath); + break; + + default: + toRestore.push(resource.fsPath); + break; + } + } + + if (toClean.length > 0) { + await this._clean(toClean); + } + + if (toRestore.length > 0) { + await this.repository.restore(toRestore, options); } + + this.closeDiffEditors([], [...toClean, ...toRestore]); + + // Clear AI contribution tracking for discarded resources + commands.executeCommand('_aiEdits.clearAiContributions', resources); }); } @@ -1511,9 +1542,6 @@ export class Repository implements Disposable { } async clean(resources: Uri[]): Promise { - const config = workspace.getConfiguration('git'); - const discardUntrackedChangesToTrash = config.get('discardUntrackedChangesToTrash', true) && !isRemote && !isLinuxSnap; - await this.run( Operation.Clean(!this.optimisticUpdateEnabled()), async () => { @@ -1552,33 +1580,7 @@ export class Repository implements Disposable { }); if (toClean.length > 0) { - if (discardUntrackedChangesToTrash) { - try { - // Attempt to move the first resource to the recycle bin/trash to check - // if it is supported. If it fails, we show a confirmation dialog and - // fall back to deletion. - await workspace.fs.delete(Uri.file(toClean[0]), { useTrash: true }); - - const limiter = new Limiter(5); - await Promise.all(toClean.slice(1).map(fsPath => limiter.queue( - async () => await workspace.fs.delete(Uri.file(fsPath), { useTrash: true })))); - } catch { - const message = isWindows - ? l10n.t('Failed to delete using the Recycle Bin. Do you want to permanently delete instead?') - : l10n.t('Failed to delete using the Trash. Do you want to permanently delete instead?'); - const primaryAction = toClean.length === 1 - ? l10n.t('Delete File') - : l10n.t('Delete All {0} Files', resources.length); - - const result = await window.showWarningMessage(message, { modal: true }, primaryAction); - if (result === primaryAction) { - // Delete permanently - await this.repository.clean(toClean); - } - } - } else { - await this.repository.clean(toClean); - } + await this._clean(toClean); } if (toCheckout.length > 0) { @@ -1615,6 +1617,43 @@ export class Repository implements Disposable { }); } + async _clean(resources: string[]): Promise { + const config = workspace.getConfiguration('git'); + const discardUntrackedChangesToTrash = config.get('discardUntrackedChangesToTrash', true) && !isRemote && !isLinuxSnap; + + if (resources.length === 0) { + return; + } + + if (discardUntrackedChangesToTrash) { + try { + // Attempt to move the first resource to the recycle bin/trash to check + // if it is supported. If it fails, we show a confirmation dialog and + // fall back to deletion. + await workspace.fs.delete(Uri.file(resources[0]), { useTrash: true }); + + const limiter = new Limiter(5); + await Promise.all(resources.slice(1).map(fsPath => limiter.queue( + async () => await workspace.fs.delete(Uri.file(fsPath), { useTrash: true })))); + } catch { + const message = isWindows + ? l10n.t('Failed to delete using the Recycle Bin. Do you want to permanently delete instead?') + : l10n.t('Failed to delete using the Trash. Do you want to permanently delete instead?'); + const primaryAction = resources.length === 1 + ? l10n.t('Delete File') + : l10n.t('Delete All {0} Files', resources.length); + + const result = await window.showWarningMessage(message, { modal: true }, primaryAction); + if (result === primaryAction) { + // Delete permanently + await this.repository.clean(resources); + } + } + } else { + await this.repository.clean(resources); + } + } + closeDiffEditors(indexResources: string[] | undefined, workingTreeResources: string[] | undefined, ignoreSetting = false): void { const config = workspace.getConfiguration('git', Uri.file(this.root)); if (!config.get('closeDiffOnOperation', false) && !ignoreSetting) { return; } diff --git a/extensions/grunt/src/main.ts b/extensions/grunt/src/main.ts index 9c2fee82b50a8..b94b00c44621e 100644 --- a/extensions/grunt/src/main.ts +++ b/extensions/grunt/src/main.ts @@ -18,9 +18,9 @@ function exists(file: string): Promise { }); } -function exec(command: string, args: string[], options: cp.ExecFileOptions): Promise<{ stdout: string; stderr: string }> { +function exec(command: string, options: cp.ExecOptions): Promise<{ stdout: string; stderr: string }> { return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { - cp.execFile(command, args, options, (error, stdout, stderr) => { + cp.exec(command, options, (error, stdout, stderr) => { if (error) { reject({ error, stdout, stderr }); } @@ -143,9 +143,9 @@ class FolderDetector { return emptyTasks; } - const gruntCommand = await this._gruntCommand; + const commandLine = `${await this._gruntCommand} --help --no-color`; try { - const { stdout, stderr } = await exec(gruntCommand, ['--help', '--no-color'], { cwd: rootPath }); + const { stdout, stderr } = await exec(commandLine, { cwd: rootPath }); if (stderr) { getOutputChannel().appendLine(stderr); showError(); diff --git a/extensions/html-language-features/client/src/htmlClient.ts b/extensions/html-language-features/client/src/htmlClient.ts index 250b340cf6229..cfb91c1fa42a1 100644 --- a/extensions/html-language-features/client/src/htmlClient.ts +++ b/extensions/html-language-features/client/src/htmlClient.ts @@ -317,14 +317,12 @@ async function startClientWithParticipants(languageParticipants: LanguagePartici const snippetProposal = new CompletionItem('HTML sample', CompletionItemKind.Snippet); snippetProposal.range = range; const content = ['', - '', + '', '', '\t', - '\t', - '\t${1:Page Title}', '\t', - '\t', - '\t', + '\t${2:Page Title}', + '\t', '', '', '\t$0', diff --git a/extensions/vscode-api-tests/package-lock.json b/extensions/vscode-api-tests/package-lock.json index 644c4fdd0170e..430653e72ddf6 100644 --- a/extensions/vscode-api-tests/package-lock.json +++ b/extensions/vscode-api-tests/package-lock.json @@ -12,7 +12,7 @@ "@types/mocha": "^10.0.10", "@types/node": "22.x", "@types/node-forge": "^1.3.11", - "node-forge": "^1.3.2", + "node-forge": "^1.4.0", "straightforward": "^4.2.2" }, "engines": { @@ -158,9 +158,9 @@ "dev": true }, "node_modules/node-forge": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.2.tgz", - "integrity": "sha512-6xKiQ+cph9KImrRh0VsjH2d8/GXA4FIMlgU4B757iI1ApvcyA9VlouP0yZJha01V+huImO+kKMU7ih+2+E14fw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", "dev": true, "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { diff --git a/extensions/vscode-api-tests/package.json b/extensions/vscode-api-tests/package.json index 0ba8a2d299954..7403fed1bc7ce 100644 --- a/extensions/vscode-api-tests/package.json +++ b/extensions/vscode-api-tests/package.json @@ -287,7 +287,7 @@ "@types/mocha": "^10.0.10", "@types/node": "22.x", "@types/node-forge": "^1.3.11", - "node-forge": "^1.3.2", + "node-forge": "^1.4.0", "straightforward": "^4.2.2" }, "repository": { diff --git a/package.json b/package.json index d8e857c1969b0..eb71aa834098e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.114.0", - "distro": "a305f48172def2b6e043c61177258d2d1abe7c0b", + "distro": "3431ef6bcc28695f128a7a364e73b474b103df99", "author": { "name": "Microsoft Corporation" }, diff --git a/src/vs/base/common/codicons.ts b/src/vs/base/common/codicons.ts index b84311aa21193..e73173e2d5e12 100644 --- a/src/vs/base/common/codicons.ts +++ b/src/vs/base/common/codicons.ts @@ -47,6 +47,8 @@ export const codiconsDerived = { gitFetch: register('git-fetch', 0xec1d), lightbulbSparkleAutofix: register('lightbulb-sparkle-autofix', 0xec1f), debugBreakpointPending: register('debug-breakpoint-pending', 0xebd9), + chatImport: register('chat-import', 0xec86), + chatExport: register('chat-export', 0xec87), } as const; diff --git a/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts b/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts index 54eba157fb3f6..2842c3e208101 100644 --- a/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts +++ b/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts @@ -80,8 +80,12 @@ export class AgentHostFileSystemProvider extends Disposable implements IFileSyst if (path === '/' || path === '') { return { type: FileType.Directory, mtime: 0, ctime: 0, size: 0, permissions: FilePermission.Readonly }; } - const decodedPath = fromAgentHostUri(resource).path; - if (decodedPath === '/' || decodedPath === '') { + const decoded = fromAgentHostUri(resource); + if (decoded.scheme === 'session-db') { + return { type: FileType.File, mtime: 0, ctime: 0, size: 0, permissions: FilePermission.Readonly }; + } + + if (decoded.path === '/' || decoded.path === '') { return { type: FileType.Directory, mtime: 0, ctime: 0, size: 0, permissions: FilePermission.Readonly }; } diff --git a/src/vs/platform/agentHost/common/agentHostUri.ts b/src/vs/platform/agentHost/common/agentHostUri.ts index 773bce4e67643..157277a921357 100644 --- a/src/vs/platform/agentHost/common/agentHostUri.ts +++ b/src/vs/platform/agentHost/common/agentHostUri.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; +import { Schemas } from '../../../base/common/network.js'; import { URI } from '../../../base/common/uri.js'; import type { ResourceLabelFormatter } from '../../label/common/label.js'; @@ -34,7 +35,7 @@ export const AGENT_HOST_SCHEME = 'vscode-agent-host'; * the URI authority (from {@link agentHostAuthority}). */ export function toAgentHostUri(originalUri: URI, connectionAuthority: string): URI { - if (connectionAuthority === 'local') { + if (connectionAuthority === 'local' && originalUri.scheme === Schemas.file) { return originalUri; } diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts index dcc52fde5565a..61bc8ba757bff 100644 --- a/src/vs/platform/agentHost/common/sessionDataService.ts +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -3,11 +3,92 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { IDisposable, IReference } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; export const ISessionDataService = createDecorator('sessionDataService'); +// ---- File-edit types ---------------------------------------------------- + +/** + * Lightweight metadata for a file edit. Returned by {@link ISessionDatabase.getFileEdits} + * without the (potentially large) file content blobs. + */ +export interface IFileEditRecord { + /** The turn that owns this file edit. */ + turnId: string; + /** The tool call that produced this edit. */ + toolCallId: string; + /** Absolute file path that was edited. */ + filePath: string; + /** Number of lines added (informational, for diff metadata). */ + addedLines: number | undefined; + /** Number of lines removed (informational, for diff metadata). */ + removedLines: number | undefined; +} + +/** + * The before/after content blobs for a single file edit. + * Retrieved on demand via {@link ISessionDatabase.readFileEditContent}. + */ +export interface IFileEditContent { + /** File content before the edit (may be empty for newly created files). */ + beforeContent: Uint8Array; + /** File content after the edit. */ + afterContent: Uint8Array; +} + +// ---- Session database --------------------------------------------------- + +/** + * A disposable handle to a per-session SQLite database backed by + * `@vscode/sqlite3`. + * + * Callers obtain an instance via {@link ISessionDataService.openDatabase} and + * **must** dispose it when finished to close the underlying database connection. + */ +export interface ISessionDatabase extends IDisposable { + /** + * Create a turn record. Must be called before storing file edits that + * reference this turn. + */ + createTurn(turnId: string): Promise; + + /** + * Delete a turn and all of its associated file edits (cascade). + */ + deleteTurn(turnId: string): Promise; + + /** + * Store a file-edit snapshot (metadata + content) for a tool invocation + * within a turn. + * + * If a record for the same `toolCallId` and `filePath` already exists + * it is replaced. + */ + storeFileEdit(edit: IFileEditRecord & IFileEditContent): Promise; + + /** + * Retrieve file-edit metadata for the given tool call IDs. + * Content blobs are **not** included — use {@link readFileEditContent} + * to fetch them on demand. Results are returned in insertion order. + */ + getFileEdits(toolCallIds: string[]): Promise; + + /** + * Read the before/after content blobs for a single file edit. + * Returns `undefined` if no edit exists for the given key. + */ + readFileEditContent(toolCallId: string, filePath: string): Promise; + + /** + * Close the database connection. After calling this method, the object is + * considered disposed and all other methods will reject with an error. + */ + close(): Promise; +} + /** * Provides persistent, per-session data directories on disk. * @@ -34,6 +115,17 @@ export interface ISessionDataService { */ getSessionDataDirById(sessionId: string): URI; + /** + * Opens (or creates) a per-session SQLite database. The database file is + * stored at `{sessionDataDir}/session.db`. Migrations are applied + * automatically on first use. + * + * Returns a ref-counted reference. Multiple callers for the same session + * share the same underlying connection. The connection is closed when + * the last reference is disposed. + */ + openDatabase(session: URI): IReference; + /** * Recursively deletes the data directory for a session, if it exists. */ diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index e4d16a198b244..b90fa91c0c071 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -171,15 +171,18 @@ export class AgentService extends Disposable implements IAgentService { await provider.disposeSession(session); this._sessionToProvider.delete(session.toString()); } - this._stateManager.removeSession(session.toString()); - this._sessionDataService.deleteSessionData(session); + this._stateManager.deleteSession(session.toString()); } // ---- Protocol methods --------------------------------------------------- async subscribe(resource: URI): Promise { this._logService.trace(`[AgentService] subscribe: ${resource.toString()}`); - const snapshot = this._stateManager.getSnapshot(resource.toString()); + let snapshot = this._stateManager.getSnapshot(resource.toString()); + if (!snapshot) { + await this.restoreSession(resource); + snapshot = this._stateManager.getSnapshot(resource.toString()); + } if (!snapshot) { throw new Error(`Cannot subscribe to unknown resource: ${resource.toString()}`); } diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index b7592c59f4720..fa90488d64461 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -29,6 +29,7 @@ import { type URI as ProtocolURI, } from '../common/state/sessionState.js'; import { AgentEventMapper } from './agentEventMapper.js'; +import { ISessionDbUriFields, parseSessionDbUri } from './copilot/fileEditTracker.js'; import type { IProtocolSideEffectHandler } from './protocolServerHandler.js'; import { SessionStateManager } from './sessionStateManager.js'; @@ -339,8 +340,7 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH handleDisposeSession(session: ProtocolURI): void { const agent = this._options.getAgent(session); agent?.disposeSession(URI.parse(session)).catch(() => { }); - this._stateManager.removeSession(session); - this._options.sessionDataService.deleteSessionData(URI.parse(session)); + this._stateManager.deleteSession(session); } async handleListSessions(): Promise { @@ -567,6 +567,13 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH } async handleFetchContent(uri: ProtocolURI): Promise { + // Handle session-db: URIs that reference file-edit content stored + // in a per-session SQLite database. + const dbFields = parseSessionDbUri(uri); + if (dbFields) { + return this._fetchSessionDbContent(dbFields); + } + try { const content = await this._fileService.readFile(URI.parse(uri)); return { @@ -579,6 +586,25 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH } } + private async _fetchSessionDbContent(fields: ISessionDbUriFields): Promise { + const sessionUri = URI.parse(fields.sessionUri); + const ref = this._options.sessionDataService.openDatabase(sessionUri); + try { + const content = await ref.object.readFileEditContent(fields.toolCallId, fields.filePath); + if (!content) { + throw new ProtocolError(AhpErrorCodes.NotFound, `File edit not found: toolCallId=${fields.toolCallId}, filePath=${fields.filePath}`); + } + const bytes = fields.part === 'before' ? content.beforeContent : content.afterContent; + return { + data: new TextDecoder().decode(bytes), + encoding: ContentEncoding.Utf8, + contentType: 'text/plain', + }; + } finally { + ref.dispose(); + } + } + override dispose(): void { this._toolCallAgents.clear(); super.dispose(); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 31a4fad42e9c2..443082caeff68 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -3,11 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CopilotClient, CopilotSession, type SessionEvent, type SessionEventPayload } from '@github/copilot-sdk'; +import { CopilotClient, CopilotSession } from '@github/copilot-sdk'; import { rgPath } from '@vscode/ripgrep'; import { DeferredPromise } from '../../../../base/common/async.js'; import { Emitter } from '../../../../base/common/event.js'; -import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, IReference } from '../../../../base/common/lifecycle.js'; import { FileAccess } from '../../../../base/common/network.js'; import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; import { delimiter, dirname } from '../../../../base/common/path.js'; @@ -16,11 +16,12 @@ import { IFileService } from '../../../files/common/files.js'; import { ILogService } from '../../../log/common/log.js'; import { localize } from '../../../../nls.js'; import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentSessionMetadata, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; -import { ISessionDataService } from '../../common/sessionDataService.js'; +import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { ToolResultContentType, type IPendingMessage, type IToolResultContent, type PolicyState } from '../../common/state/sessionState.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { getEditFilePath, getInvocationMessage, getPastTenseMessage, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool } from './copilotToolDisplay.js'; import { FileEditTracker } from './fileEditTracker.js'; +import { mapSessionEvents } from './mapSessionEvents.js'; function tryStringify(value: unknown): string | undefined { try { @@ -51,6 +52,8 @@ export class CopilotAgent extends Disposable implements IAgent { private readonly _sessionWorkingDirs = new Map(); /** File edit trackers per session, keyed by raw session ID. */ private readonly _editTrackers = new Map(); + /** Session database references, keyed by raw session ID. */ + private readonly _sessionDatabases = this._register(new DisposableMap>()); constructor( @ILogService private readonly _logService: ILogService, @@ -270,7 +273,13 @@ export class CopilotAgent extends Disposable implements IAgent { } const events = await entry.session.getMessages(); - return this._mapSessionEvents(session, events); + let db: ISessionDatabase | undefined; + try { + db = this._getSessionDatabase(sessionId); + } catch { + // Database may not exist yet — that's fine + } + return mapSessionEvents(session, db, events); } async disposeSession(session: URI): Promise { @@ -278,6 +287,7 @@ export class CopilotAgent extends Disposable implements IAgent { this._sessions.deleteAndDispose(sessionId); this._clearToolCallsForSession(sessionId); this._sessionWorkingDirs.delete(sessionId); + this._sessionDatabases.deleteAndDispose(sessionId); this._denyPendingPermissionsForSession(sessionId); } @@ -306,6 +316,7 @@ export class CopilotAgent extends Disposable implements IAgent { this._activeToolCalls.clear(); this._sessionWorkingDirs.clear(); this._denyPendingPermissions(); + this._sessionDatabases.clearAndDisposeAll(); await this._client?.stop(); this._client = undefined; } @@ -439,10 +450,22 @@ export class CopilotAgent extends Disposable implements IAgent { } } + private _getSessionDatabase(rawSessionId: string): ISessionDatabase { + let ref = this._sessionDatabases.get(rawSessionId); + if (!ref) { + const session = AgentSession.uri(this.id, rawSessionId); + ref = this._sessionDataService.openDatabase(session); + this._sessionDatabases.set(rawSessionId, ref); + } + return ref.object; + } + private _getOrCreateEditTracker(rawSessionId: string): FileEditTracker { let tracker = this._editTrackers.get(rawSessionId); if (!tracker) { - tracker = new FileEditTracker(rawSessionId, this._sessionDataService, this._fileService, this._logService); + const session = AgentSession.uri(this.id, rawSessionId); + const db = this._getSessionDatabase(rawSessionId); + tracker = new FileEditTracker(session.toString(), db, this._fileService, this._logService); this._editTrackers.set(rawSessionId, tracker); } return tracker; @@ -547,6 +570,11 @@ export class CopilotAgent extends Disposable implements IAgent { }); }); + let turnId: string = ''; + wrapper.onTurnStart(e => { + turnId = e.data.turnId; + }); + wrapper.onToolComplete(e => { const trackingKey = `${rawId}:${e.data.toolCallId}`; const tracked = this._activeToolCalls.get(trackingKey); @@ -567,7 +595,7 @@ export class CopilotAgent extends Disposable implements IAgent { const tracker = this._editTrackers.get(rawId); const filePath = isEditTool(tracked.toolName) ? getEditFilePath(tracked.parameters) : undefined; if (tracker && filePath) { - const fileEdit = tracker.takeCompletedEdit(filePath); + const fileEdit = tracker.takeCompletedEdit(turnId, e.data.toolCallId, filePath); if (fileEdit) { content.push(fileEdit); } @@ -761,89 +789,6 @@ export class CopilotAgent extends Disposable implements IAgent { return this._trackSession(raw, sessionId); } - private _mapSessionEvents(session: URI, events: readonly SessionEvent[]): (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[] { - const result: (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[] = []; - const toolInfoByCallId = new Map | undefined }>(); - - for (const e of events) { - if (e.type === 'assistant.message' || e.type === 'user.message') { - const d = (e as SessionEventPayload<'assistant.message'>).data; - result.push({ - session, - type: 'message', - role: e.type === 'user.message' ? 'user' : 'assistant', - messageId: d?.messageId ?? '', - content: d?.content ?? '', - toolRequests: d?.toolRequests?.map((tr: { toolCallId: string; name: string; arguments?: unknown; type?: 'function' | 'custom' }) => ({ - toolCallId: tr.toolCallId, - name: tr.name, - arguments: tr.arguments !== undefined ? tryStringify(tr.arguments) : undefined, - type: tr.type, - })), - reasoningOpaque: d?.reasoningOpaque, - reasoningText: d?.reasoningText, - encryptedContent: d?.encryptedContent, - parentToolCallId: d?.parentToolCallId, - }); - } else if (e.type === 'tool.execution_start') { - const d = (e as SessionEventPayload<'tool.execution_start'>).data; - if (isHiddenTool(d.toolName)) { - continue; - } - const toolArgs = d.arguments !== undefined ? tryStringify(d.arguments) : undefined; - let parameters: Record | undefined; - if (toolArgs) { - try { parameters = JSON.parse(toolArgs) as Record; } catch { /* ignore */ } - } - toolInfoByCallId.set(d.toolCallId, { toolName: d.toolName, parameters }); - const displayName = getToolDisplayName(d.toolName); - const toolKind = getToolKind(d.toolName); - result.push({ - session, - type: 'tool_start', - toolCallId: d.toolCallId, - toolName: d.toolName, - displayName, - invocationMessage: getInvocationMessage(d.toolName, displayName, parameters), - toolInput: getToolInputString(d.toolName, parameters, toolArgs), - toolKind, - language: toolKind === 'terminal' ? getShellLanguage(d.toolName) : undefined, - toolArguments: toolArgs, - mcpServerName: d.mcpServerName, - mcpToolName: d.mcpToolName, - parentToolCallId: d.parentToolCallId, - }); - } else if (e.type === 'tool.execution_complete') { - const d = (e as SessionEventPayload<'tool.execution_complete'>).data; - const info = toolInfoByCallId.get(d.toolCallId); - if (!info) { - continue; - } - toolInfoByCallId.delete(d.toolCallId); - const displayName = getToolDisplayName(info.toolName); - const toolOutput = d.error?.message ?? d.result?.content; - const content: IToolResultContent[] = []; - if (toolOutput !== undefined) { - content.push({ type: ToolResultContentType.Text, text: toolOutput }); - } - result.push({ - session, - type: 'tool_complete', - toolCallId: d.toolCallId, - result: { - success: d.success, - pastTenseMessage: getPastTenseMessage(info.toolName, displayName, info.parameters, d.success), - content: content.length > 0 ? content : undefined, - error: d.error, - }, - isUserRequested: d.isUserRequested, - toolTelemetry: d.toolTelemetry !== undefined ? tryStringify(d.toolTelemetry) : undefined, - }); - } - } - return result; - } - override dispose(): void { this._denyPendingPermissions(); this._client?.stop().catch(() => { /* best-effort */ }); diff --git a/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts b/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts index 8ee6b5a089329..7eee79b096e26 100644 --- a/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts +++ b/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts @@ -3,16 +3,64 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { VSBuffer } from '../../../../base/common/buffer.js'; +import { decodeHex, encodeHex, VSBuffer } from '../../../../base/common/buffer.js'; import { URI } from '../../../../base/common/uri.js'; import { IFileService } from '../../../files/common/files.js'; import { ILogService } from '../../../log/common/log.js'; -import { ISessionDataService } from '../../common/sessionDataService.js'; +import { ISessionDatabase } from '../../common/sessionDataService.js'; import { ToolResultContentType, type IToolResultFileEditContent } from '../../common/state/sessionState.js'; +const SESSION_DB_SCHEME = 'session-db'; + +/** + * Builds a `session-db:` URI that references a file-edit content blob + * stored in the session database. Parsed by {@link parseSessionDbUri}. + */ +export function buildSessionDbUri(sessionUri: string, toolCallId: string, filePath: string, part: 'before' | 'after'): string { + return URI.from({ + scheme: SESSION_DB_SCHEME, + authority: encodeHex(VSBuffer.fromString(sessionUri)).toString(), + path: `/${encodeURIComponent(toolCallId)}/${encodeHex(VSBuffer.fromString(filePath))}/${part}`, + }).toString(); +} + +/** Parsed fields from a `session-db:` content URI. */ +export interface ISessionDbUriFields { + sessionUri: string; + toolCallId: string; + filePath: string; + part: 'before' | 'after'; +} + +/** + * Parses a `session-db:` URI produced by {@link buildSessionDbUri}. + * Returns `undefined` if the URI is not a valid `session-db:` URI. + */ +export function parseSessionDbUri(raw: string): ISessionDbUriFields | undefined { + const parsed = URI.parse(raw); + if (parsed.scheme !== SESSION_DB_SCHEME) { + return undefined; + } + const [, toolCallId, filePath, part] = parsed.path.split('/'); + if (!toolCallId || !filePath || (part !== 'before' && part !== 'after')) { + return undefined; + } + try { + return { + sessionUri: decodeHex(parsed.authority).toString(), + toolCallId: decodeURIComponent(toolCallId), + filePath: decodeHex(filePath).toString(), + part + }; + } catch { + return undefined; + } +} + /** * Tracks file edits made by tools in a session by snapshotting file content - * before and after each edit tool invocation. + * before and after each edit tool invocation, persisting snapshots into the + * session database. */ export class FileEditTracker { @@ -20,45 +68,41 @@ export class FileEditTracker { * Pending edits keyed by file path. The `onPreToolUse` hook stores * entries here; `completeEdit` pops them when the tool finishes. */ - private readonly _pendingEdits = new Map }>(); + private readonly _pendingEdits = new Map }>(); /** * Completed edits keyed by file path. The `onPostToolUse` hook stores - * entries here; `takeCompletedEdit` retrieves them synchronously from - * the `onToolComplete` handler. + * entries here; `takeCompletedEdit` retrieves them from the + * `onToolComplete` handler and persists to the database. */ - private readonly _completedEdits = new Map(); + private readonly _completedEdits = new Map(); constructor( - private readonly _sessionId: string, - private readonly _sessionDataService: ISessionDataService, + private readonly _sessionUri: string, + private readonly _db: ISessionDatabase, private readonly _fileService: IFileService, private readonly _logService: ILogService, ) { } /** * Call from the `onPreToolUse` hook before an edit tool runs. - * Snapshots the file's current content as the "before" state. + * Reads the file's current content into memory as the "before" state. * The hook blocks the SDK until this returns, ensuring the snapshot * captures pre-edit content. * * @param filePath - Absolute path of the file being edited. */ async trackEditStart(filePath: string): Promise { - const editKey = generateEditKey(); - const sessionDataDir = this._sessionDataService.getSessionDataDirById(this._sessionId); - const beforeUri = URI.joinPath(sessionDataDir, 'file-edits', editKey, 'before'); - - const snapshotDone = this._snapshotFile(filePath, beforeUri); - this._pendingEdits.set(filePath, { editKey, beforeUri, snapshotDone }); - await snapshotDone; + const snapshotDone = this._readFile(filePath); + const entry = { beforeContent: VSBuffer.fromString(''), snapshotDone: snapshotDone.then(buf => { entry.beforeContent = buf; }) }; + this._pendingEdits.set(filePath, entry); + await entry.snapshotDone; } /** * Call from the `onPostToolUse` hook after an edit tool finishes. - * Stores the result for later synchronous retrieval via {@link takeCompletedEdit}. - * The `beforeURI` points to the stored snapshot; the `afterURI` is - * the real file path (the tool already modified it on disk). + * Reads the file content again as the "after" state and stores the + * result for later retrieval via {@link takeCompletedEdit}. * * @param filePath - Absolute path of the file that was edited. */ @@ -70,44 +114,56 @@ export class FileEditTracker { this._pendingEdits.delete(filePath); await pending.snapshotDone; - // Snapshot the after-content into session data so it remains - // stable even if the file is modified again later. - const sessionDataDir = this._sessionDataService.getSessionDataDirById(this._sessionId); - const afterUri = URI.joinPath(sessionDataDir, 'file-edits', pending.editKey, 'after'); - await this._snapshotFile(filePath, afterUri); + const afterContent = await this._readFile(filePath); this._completedEdits.set(filePath, { - type: ToolResultContentType.FileEdit, - beforeURI: pending.beforeUri.toString(), - afterURI: afterUri.toString(), + beforeContent: pending.beforeContent, + afterContent, }); } /** - * Synchronously retrieves and removes a completed edit for the given - * file path. Call from the `onToolComplete` handler to include the - * edit in the tool result without async work. + * Retrieves and removes a completed edit for the given file path, + * persists it to the session database, and returns the result as an + * {@link IToolResultFileEditContent} for inclusion in the tool result. + * + * @param toolCallId - The tool call that produced this edit. + * @param filePath - Absolute path of the edited file. */ - takeCompletedEdit(filePath: string): IToolResultFileEditContent | undefined { + takeCompletedEdit(turnId: string, toolCallId: string, filePath: string): IToolResultFileEditContent | undefined { const edit = this._completedEdits.get(filePath); - if (edit) { - this._completedEdits.delete(filePath); + if (!edit) { + return undefined; } - return edit; + this._completedEdits.delete(filePath); + + const beforeBytes = edit.beforeContent.buffer; + const afterBytes = edit.afterContent.buffer; + + this._db.storeFileEdit({ + turnId, + toolCallId, + filePath, + beforeContent: beforeBytes, + afterContent: afterBytes, + addedLines: undefined, + removedLines: undefined, + }).catch(err => this._logService.warn(`[FileEditTracker] Failed to persist file edit to database: ${filePath}`, err)); + + return { + type: ToolResultContentType.FileEdit, + beforeURI: buildSessionDbUri(this._sessionUri, toolCallId, filePath, 'before'), + afterURI: buildSessionDbUri(this._sessionUri, toolCallId, filePath, 'after'), + }; } - private async _snapshotFile(filePath: string, targetUri: URI): Promise { + private async _readFile(filePath: string): Promise { try { const content = await this._fileService.readFile(URI.file(filePath)); - await this._fileService.writeFile(targetUri, content.value); + return content.value; } catch (err) { this._logService.trace(`[FileEditTracker] Could not read file for snapshot: ${filePath}`, err); - await this._fileService.writeFile(targetUri, VSBuffer.fromString('')).catch(() => { }); + return VSBuffer.fromString(''); } } } - -let _editKeyCounter = 0; -function generateEditKey(): string { - return `${Date.now()}-${_editKeyCounter++}`; -} diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts new file mode 100644 index 0000000000000..6f697f02cae24 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -0,0 +1,216 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../base/common/uri.js'; +import { IAgentMessageEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; +import { IFileEditRecord, ISessionDatabase } from '../../common/sessionDataService.js'; +import { ToolResultContentType, type IToolResultContent } from '../../common/state/sessionState.js'; +import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool } from './copilotToolDisplay.js'; +import { buildSessionDbUri } from './fileEditTracker.js'; + +function tryStringify(value: unknown): string | undefined { + try { + return JSON.stringify(value); + } catch { + return undefined; + } +} + +// ---- Minimal event shapes matching the SDK's SessionEvent union --------- +// Defined here so tests can construct events without importing the SDK. + +export interface ISessionEventToolStart { + type: 'tool.execution_start'; + data: { + toolCallId: string; + toolName: string; + arguments?: unknown; + mcpServerName?: string; + mcpToolName?: string; + parentToolCallId?: string; + }; +} + +export interface ISessionEventToolComplete { + type: 'tool.execution_complete'; + data: { + toolCallId: string; + success: boolean; + result?: { content?: string }; + error?: { message: string; code?: string }; + isUserRequested?: boolean; + toolTelemetry?: unknown; + parentToolCallId?: string; + }; +} + +export interface ISessionEventMessage { + type: 'assistant.message' | 'user.message'; + data?: { + messageId?: string; + content?: string; + toolRequests?: readonly { toolCallId: string; name: string; arguments?: unknown; type?: 'function' | 'custom' }[]; + reasoningOpaque?: string; + reasoningText?: string; + encryptedContent?: string; + parentToolCallId?: string; + }; +} + +/** Minimal event shape for session history mapping. */ +export type ISessionEvent = ISessionEventToolStart | ISessionEventToolComplete | ISessionEventMessage | { type: string; data?: unknown }; + +/** + * Maps raw SDK session events into agent protocol events, restoring + * stored file-edit metadata from the session database when available. + * + * Extracted as a standalone function so it can be tested without the + * full CopilotAgent or SDK dependencies. + */ +export async function mapSessionEvents( + session: URI, + db: ISessionDatabase | undefined, + events: readonly ISessionEvent[], +): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]> { + const result: (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[] = []; + const toolInfoByCallId = new Map | undefined }>(); + + // Collect all tool call IDs for edit tools so we can batch-query the database + const editToolCallIds: string[] = []; + + // First pass: collect tool info and identify edit tool calls + for (const e of events) { + if (e.type === 'tool.execution_start') { + const d = (e as ISessionEventToolStart).data; + if (isHiddenTool(d.toolName)) { + continue; + } + const toolArgs = d.arguments !== undefined ? tryStringify(d.arguments) : undefined; + let parameters: Record | undefined; + if (toolArgs) { + try { parameters = JSON.parse(toolArgs) as Record; } catch { /* ignore */ } + } + toolInfoByCallId.set(d.toolCallId, { toolName: d.toolName, parameters }); + if (isEditTool(d.toolName)) { + editToolCallIds.push(d.toolCallId); + } + } + } + + // Query the database for stored file edits (metadata only) + let storedEdits: Map | undefined; + if (db && editToolCallIds.length > 0) { + try { + const records = await db.getFileEdits(editToolCallIds); + if (records.length > 0) { + storedEdits = new Map(); + for (const r of records) { + let list = storedEdits.get(r.toolCallId); + if (!list) { + list = []; + storedEdits.set(r.toolCallId, list); + } + list.push(r); + } + } + } catch (_e) { + // Database may not exist yet for new sessions — that's fine + } + } + + const sessionUriStr = session.toString(); + + // Second pass: build result events + for (const e of events) { + if (e.type === 'assistant.message' || e.type === 'user.message') { + const d = (e as ISessionEventMessage).data; + result.push({ + session, + type: 'message', + role: e.type === 'user.message' ? 'user' : 'assistant', + messageId: d?.messageId ?? '', + content: d?.content ?? '', + toolRequests: d?.toolRequests?.map((tr) => ({ + toolCallId: tr.toolCallId, + name: tr.name, + arguments: tr.arguments !== undefined ? tryStringify(tr.arguments) : undefined, + type: tr.type, + })), + reasoningOpaque: d?.reasoningOpaque, + reasoningText: d?.reasoningText, + encryptedContent: d?.encryptedContent, + parentToolCallId: d?.parentToolCallId, + }); + } else if (e.type === 'tool.execution_start') { + const d = (e as ISessionEventToolStart).data; + if (isHiddenTool(d.toolName)) { + continue; + } + const info = toolInfoByCallId.get(d.toolCallId); + const displayName = getToolDisplayName(d.toolName); + const toolKind = getToolKind(d.toolName); + const toolArgs = d.arguments !== undefined ? tryStringify(d.arguments) : undefined; + result.push({ + session, + type: 'tool_start', + toolCallId: d.toolCallId, + toolName: d.toolName, + displayName, + invocationMessage: getInvocationMessage(d.toolName, displayName, info?.parameters), + toolInput: getToolInputString(d.toolName, info?.parameters, toolArgs), + toolKind, + language: toolKind === 'terminal' ? getShellLanguage(d.toolName) : undefined, + toolArguments: toolArgs, + mcpServerName: d.mcpServerName, + mcpToolName: d.mcpToolName, + parentToolCallId: d.parentToolCallId, + }); + } else if (e.type === 'tool.execution_complete') { + const d = (e as ISessionEventToolComplete).data; + const info = toolInfoByCallId.get(d.toolCallId); + if (!info) { + continue; + } + toolInfoByCallId.delete(d.toolCallId); + const displayName = getToolDisplayName(info.toolName); + const toolOutput = d.error?.message ?? d.result?.content; + const content: IToolResultContent[] = []; + if (toolOutput !== undefined) { + content.push({ type: ToolResultContentType.Text, text: toolOutput }); + } + + // Restore file edit content references from the database + const edits = storedEdits?.get(d.toolCallId); + if (edits) { + for (const edit of edits) { + content.push({ + type: ToolResultContentType.FileEdit, + beforeURI: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'before'), + afterURI: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'after'), + diff: (edit.addedLines !== undefined || edit.removedLines !== undefined) + ? { added: edit.addedLines, removed: edit.removedLines } + : undefined, + }); + } + } + + result.push({ + session, + type: 'tool_complete', + toolCallId: d.toolCallId, + result: { + success: d.success, + pastTenseMessage: getPastTenseMessage(info.toolName, displayName, info.parameters, d.success), + content: content.length > 0 ? content : undefined, + error: d.error, + }, + isUserRequested: d.isUserRequested, + toolTelemetry: d.toolTelemetry !== undefined ? tryStringify(d.toolTelemetry) : undefined, + parentToolCallId: d.parentToolCallId, + }); + } + } + return result; +} diff --git a/src/vs/platform/agentHost/node/sessionDataService.ts b/src/vs/platform/agentHost/node/sessionDataService.ts index 5bbcb89dc076b..007be7ee4f9b7 100644 --- a/src/vs/platform/agentHost/node/sessionDataService.ts +++ b/src/vs/platform/agentHost/node/sessionDataService.ts @@ -3,11 +3,32 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { IReference, ReferenceCollection } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; import { IFileService } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; import { AgentSession } from '../common/agentService.js'; -import { ISessionDataService } from '../common/sessionDataService.js'; +import { ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js'; +import { SessionDatabase } from './sessionDatabase.js'; + +class SessionDatabaseCollection extends ReferenceCollection { + constructor( + private readonly _getDbPath: (key: string) => string, + private readonly _logService: ILogService, + ) { + super(); + } + + protected createReferencedObject(key: string): ISessionDatabase { + const dbPath = this._getDbPath(key); + this._logService.trace(`[SessionDataService] Opening database: ${dbPath}`); + return new SessionDatabase(dbPath); + } + + protected destroyReferencedObject(_key: string, object: ISessionDatabase): void { + object.dispose(); + } +} /** * Implementation of {@link ISessionDataService} that stores per-session data @@ -17,6 +38,7 @@ export class SessionDataService implements ISessionDataService { declare readonly _serviceBrand: undefined; private readonly _basePath: URI; + private readonly _databases: SessionDatabaseCollection; constructor( userDataPath: URI, @@ -24,6 +46,10 @@ export class SessionDataService implements ISessionDataService { @ILogService private readonly _logService: ILogService, ) { this._basePath = URI.joinPath(userDataPath, 'agentSessionData'); + this._databases = new SessionDatabaseCollection( + key => URI.joinPath(this._basePath, key, 'session.db').fsPath, + this._logService, + ); } getSessionDataDir(session: URI): URI { @@ -35,6 +61,11 @@ export class SessionDataService implements ISessionDataService { return URI.joinPath(this._basePath, sanitized); } + openDatabase(session: URI): IReference { + const sanitized = AgentSession.id(session).replace(/[^a-zA-Z0-9_.-]/g, '-'); + return this._databases.acquire(sanitized); + } + async deleteSessionData(session: URI): Promise { const dir = this.getSessionDataDir(session); try { diff --git a/src/vs/platform/agentHost/node/sessionDatabase.ts b/src/vs/platform/agentHost/node/sessionDatabase.ts new file mode 100644 index 0000000000000..7f960f89d2a67 --- /dev/null +++ b/src/vs/platform/agentHost/node/sessionDatabase.ts @@ -0,0 +1,316 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'fs'; +import { SequencerByKey } from '../../../base/common/async.js'; +import type { Database, RunResult } from '@vscode/sqlite3'; +import type { IFileEditContent, IFileEditRecord, ISessionDatabase } from '../common/sessionDataService.js'; +import { dirname } from '../../../base/common/path.js'; + +/** + * A single numbered migration. Migrations are applied in order of + * {@link version} and tracked via `PRAGMA user_version`. + */ +export interface ISessionDatabaseMigration { + /** Monotonically-increasing version number (1-based). */ + readonly version: number; + /** SQL to execute for this migration. */ + readonly sql: string; +} + +/** + * The set of migrations that define the current session database schema. + * New migrations should be **appended** to this array with the next version + * number. Never reorder or mutate existing entries. + */ +export const sessionDatabaseMigrations: readonly ISessionDatabaseMigration[] = [ + { + version: 1, + sql: [ + `CREATE TABLE IF NOT EXISTS turns ( + id TEXT PRIMARY KEY NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS file_edits ( + turn_id TEXT NOT NULL REFERENCES turns(id) ON DELETE CASCADE, + tool_call_id TEXT NOT NULL, + file_path TEXT NOT NULL, + before_content BLOB NOT NULL, + after_content BLOB NOT NULL, + added_lines INTEGER, + removed_lines INTEGER, + PRIMARY KEY (tool_call_id, file_path) + )`, + ].join(';\n'), + }, +]; + +// ---- Promise wrappers around callback-based @vscode/sqlite3 API ----------- + +function dbExec(db: Database, sql: string): Promise { + return new Promise((resolve, reject) => { + db.exec(sql, err => err ? reject(err) : resolve()); + }); +} + +function dbRun(db: Database, sql: string, params: unknown[]): Promise<{ changes: number; lastID: number }> { + return new Promise((resolve, reject) => { + db.run(sql, params, function (this: RunResult, err: Error | null) { + if (err) { + return reject(err); + } + resolve({ changes: this.changes, lastID: this.lastID }); + }); + }); +} + +function dbGet(db: Database, sql: string, params: unknown[]): Promise | undefined> { + return new Promise((resolve, reject) => { + db.get(sql, params, (err: Error | null, row: Record | undefined) => { + if (err) { + return reject(err); + } + resolve(row); + }); + }); +} + +function dbAll(db: Database, sql: string, params: unknown[]): Promise[]> { + return new Promise((resolve, reject) => { + db.all(sql, params, (err: Error | null, rows: Record[]) => { + if (err) { + return reject(err); + } + resolve(rows); + }); + }); +} + +function dbClose(db: Database): Promise { + return new Promise((resolve, reject) => { + db.close(err => err ? reject(err) : resolve()); + }); +} + +function dbOpen(path: string): Promise { + return new Promise((resolve, reject) => { + import('@vscode/sqlite3').then(sqlite3 => { + const db = new sqlite3.default.Database(path, (err: Error | null) => { + if (err) { + return reject(err); + } + resolve(db); + }); + }, reject); + }); +} + +/** + * Applies any pending {@link ISessionDatabaseMigration migrations} to a + * database. Migrations whose version is greater than the current + * `PRAGMA user_version` are run inside a serialized transaction. After all + * migrations complete the pragma is updated to the highest applied version. + */ +async function runMigrations(db: Database, migrations: readonly ISessionDatabaseMigration[]): Promise { + // Enable foreign key enforcement — must be set outside a transaction + // and every time a connection is opened. + await dbExec(db, 'PRAGMA foreign_keys = ON'); + + const row = await dbGet(db, 'PRAGMA user_version', []); + const currentVersion = (row?.user_version as number | undefined) ?? 0; + + const pending = migrations + .filter(m => m.version > currentVersion) + .sort((a, b) => a.version - b.version); + + if (pending.length === 0) { + return; + } + + await dbExec(db, 'BEGIN TRANSACTION'); + try { + for (const migration of pending) { + await dbExec(db, migration.sql); + // PRAGMA cannot be parameterized; the version is a trusted literal. + await dbExec(db, `PRAGMA user_version = ${migration.version}`); + } + await dbExec(db, 'COMMIT'); + } catch (err) { + await dbExec(db, 'ROLLBACK'); + throw err; + } +} + +/** + * A wrapper around a `@vscode/sqlite3` {@link Database} instance with + * lazy initialisation. + * + * The underlying connection is opened on the first async method call + * (not at construction time), allowing the object to be created + * synchronously and shared via a {@link ReferenceCollection}. + * + * Calling {@link dispose} closes the connection. + */ +export class SessionDatabase implements ISessionDatabase { + + private _dbPromise: Promise | undefined; + private _closed: Promise | true | undefined; + private readonly _fileEditSequencer = new SequencerByKey(); + + constructor( + private readonly _path: string, + private readonly _migrations: readonly ISessionDatabaseMigration[] = sessionDatabaseMigrations, + ) { } + + /** + * Opens (or creates) a SQLite database at {@link path} and applies + * any pending migrations. Only used in tests where synchronous + * construction + immediate readiness is desired. + */ + static async open(path: string, migrations: readonly ISessionDatabaseMigration[] = sessionDatabaseMigrations): Promise { + const inst = new SessionDatabase(path, migrations); + await inst._ensureDb(); + return inst; + } + + private _ensureDb(): Promise { + if (this._closed) { + return Promise.reject(new Error('SessionDatabase has been disposed')); + } + if (!this._dbPromise) { + this._dbPromise = (async () => { + // Ensure the parent directory exists before SQLite tries to + // create the database file. + await fs.promises.mkdir(dirname(this._path), { recursive: true }); + const db = await dbOpen(this._path); + try { + await runMigrations(db, this._migrations); + } catch (err) { + await dbClose(db); + this._dbPromise = undefined; + throw err; + } + // If dispose() was called while we were opening, close immediately. + if (this._closed) { + await dbClose(db); + throw new Error('SessionDatabase has been disposed'); + } + return db; + })(); + } + return this._dbPromise; + } + + /** + * Returns the names of all user-created tables in the database. + * Useful for testing migration behavior. + */ + async getAllTables(): Promise { + const db = await this._ensureDb(); + const rows = await dbAll(db, `SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`, []); + return rows.map(r => r.name as string); + } + + // ---- Turns ---------------------------------------------------------- + + async createTurn(turnId: string): Promise { + const db = await this._ensureDb(); + await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [turnId]); + } + + async deleteTurn(turnId: string): Promise { + const db = await this._ensureDb(); + await dbRun(db, 'DELETE FROM turns WHERE id = ?', [turnId]); + } + + // ---- File edits ----------------------------------------------------- + + async storeFileEdit(edit: IFileEditRecord & IFileEditContent): Promise { + return this._fileEditSequencer.queue(edit.filePath, async () => { + const db = await this._ensureDb(); + // Ensure the turn exists — the onTurnStart event that calls + // createTurn() is fire-and-forget and may not have completed yet. + await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [edit.turnId]); + await dbRun( + db, + `INSERT OR REPLACE INTO file_edits + (turn_id, tool_call_id, file_path, before_content, after_content, added_lines, removed_lines) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + edit.turnId, + edit.toolCallId, + edit.filePath, + Buffer.from(edit.beforeContent), + Buffer.from(edit.afterContent), + edit.addedLines ?? null, + edit.removedLines ?? null, + ], + ); + }); + } + + async getFileEdits(toolCallIds: string[]): Promise { + if (toolCallIds.length === 0) { + return []; + } + const db = await this._ensureDb(); + const placeholders = toolCallIds.map(() => '?').join(','); + const rows = await dbAll( + db, + `SELECT turn_id, tool_call_id, file_path, added_lines, removed_lines + FROM file_edits + WHERE tool_call_id IN (${placeholders}) + ORDER BY rowid`, + toolCallIds, + ); + return rows.map(row => ({ + turnId: row.turn_id as string, + toolCallId: row.tool_call_id as string, + filePath: row.file_path as string, + addedLines: row.added_lines as number | undefined ?? undefined, + removedLines: row.removed_lines as number | undefined ?? undefined, + })); + } + + async readFileEditContent(toolCallId: string, filePath: string): Promise { + return this._fileEditSequencer.queue(filePath, async () => { + const db = await this._ensureDb(); + const row = await dbGet( + db, + `SELECT before_content, after_content + FROM file_edits + WHERE tool_call_id = ? AND file_path = ?`, + [toolCallId, filePath], + ); + if (!row) { + return undefined; + } + return { + beforeContent: toUint8Array(row.before_content), + afterContent: toUint8Array(row.after_content), + }; + }); + } + + async close() { + await (this._closed ??= this._dbPromise?.then(db => db.close()).catch(() => { }) || true); + } + + dispose(): void { + this.close(); + } +} + +function toUint8Array(value: unknown): Uint8Array { + if (value instanceof Buffer) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + } + if (value instanceof Uint8Array) { + return value; + } + if (typeof value === 'string') { + return new TextEncoder().encode(value); + } + return new Uint8Array(0); +} diff --git a/src/vs/platform/agentHost/node/sessionStateManager.ts b/src/vs/platform/agentHost/node/sessionStateManager.ts index 531c18dd6bc32..21e2091cca086 100644 --- a/src/vs/platform/agentHost/node/sessionStateManager.ts +++ b/src/vs/platform/agentHost/node/sessionStateManager.ts @@ -143,7 +143,9 @@ export class SessionStateManager extends Disposable { } /** - * Removes a session from state and emits a sessionRemoved notification. + * Removes a session from in-memory state without emitting a notification. + * Use {@link deleteSession} when the session is being permanently deleted + * and clients need to be notified. */ removeSession(session: URI): void { const state = this._sessionStates.get(session); @@ -158,7 +160,15 @@ export class SessionStateManager extends Disposable { this._sessionStates.delete(session); this._logService.trace(`[SessionStateManager] Removed session: ${session}`); + } + /** + * Permanently deletes a session from state and emits a + * {@link NotificationType.SessionRemoved} notification so that clients + * know the session is no longer accessible. + */ + deleteSession(session: URI): void { + this.removeSession(session); this._onDidEmitNotification.fire({ type: NotificationType.SessionRemoved, session, diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 724044d8c3a2d..bbe7695e5e93c 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -26,6 +26,7 @@ suite('AgentService (node dispatcher)', () => { _serviceBrand: undefined, getSessionDataDir: () => URI.parse('inmemory:/session-data'), getSessionDataDirById: () => URI.parse('inmemory:/session-data'), + openDatabase: () => { throw new Error('not implemented'); }, deleteSessionData: async () => { }, cleanupOrphanedData: async () => { }, }; diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 3d36928963bd9..332100af04548 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -74,6 +74,7 @@ suite('AgentSideEffects', () => { _serviceBrand: undefined, getSessionDataDir: () => URI.from({ scheme: Schemas.inMemory, path: '/session-data' }), getSessionDataDirById: () => URI.from({ scheme: Schemas.inMemory, path: '/session-data' }), + openDatabase: () => { throw new Error('not implemented'); }, deleteSessionData: async () => { }, cleanupOrphanedData: async () => { }, } satisfies ISessionDataService, diff --git a/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts b/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts index 8679dab91e452..bb62eadc929e4 100644 --- a/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts +++ b/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts @@ -4,89 +4,162 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { tmpdir } from 'os'; +import { randomUUID } from 'crypto'; +import { mkdirSync, rmSync } from 'fs'; import { VSBuffer } from '../../../../base/common/buffer.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { FileService } from '../../../files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; -import { ISessionDataService } from '../../common/sessionDataService.js'; import { ToolResultContentType } from '../../common/state/sessionState.js'; -import { SessionDataService } from '../../node/sessionDataService.js'; -import { FileEditTracker } from '../../node/copilot/fileEditTracker.js'; +import { SessionDatabase } from '../../node/sessionDatabase.js'; +import { FileEditTracker, buildSessionDbUri, parseSessionDbUri } from '../../node/copilot/fileEditTracker.js'; +import { join } from '../../../../base/common/path.js'; suite('FileEditTracker', () => { const disposables = new DisposableStore(); let fileService: FileService; - let sessionDataService: ISessionDataService; + let db: SessionDatabase; let tracker: FileEditTracker; + let testDir: string; - const basePath = URI.from({ scheme: Schemas.inMemory, path: '/userData' }); + setup(async () => { + testDir = join(tmpdir(), `vscode-edit-tracker-test-${randomUUID()}`); + mkdirSync(testDir, { recursive: true }); - setup(() => { fileService = disposables.add(new FileService(new NullLogService())); - disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); - sessionDataService = new SessionDataService(basePath, fileService, new NullLogService()); - tracker = new FileEditTracker('test-session', sessionDataService, fileService, new NullLogService()); + const sourceFs = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider('file', sourceFs)); + + db = disposables.add(await SessionDatabase.open(join(testDir, 'session.db'))); + await db.createTurn('turn-1'); + + tracker = new FileEditTracker('copilot:/test-session', db, fileService, new NullLogService()); }); - teardown(() => disposables.clear()); + teardown(async () => { + disposables.clear(); + await db.close(); + rmSync(testDir, { recursive: true, force: true }); + }); ensureNoDisposablesAreLeakedInTestSuite(); test('tracks edit start and complete for existing file', async () => { - const sourceFs = disposables.add(new InMemoryFileSystemProvider()); - disposables.add(fileService.registerProvider(Schemas.file, sourceFs)); await fileService.writeFile(URI.file('/workspace/test.txt'), VSBuffer.fromString('original content\nline 2')); await tracker.trackEditStart('/workspace/test.txt'); await fileService.writeFile(URI.file('/workspace/test.txt'), VSBuffer.fromString('modified content\nline 2\nline 3')); await tracker.completeEdit('/workspace/test.txt'); - const fileEdit = tracker.takeCompletedEdit('/workspace/test.txt'); + const fileEdit = await tracker.takeCompletedEdit('turn-1', 'tc-1', '/workspace/test.txt'); assert.ok(fileEdit); assert.strictEqual(fileEdit.type, ToolResultContentType.FileEdit); - // Both URIs point to snapshots in the session data directory - const sessionDir = sessionDataService.getSessionDataDirById('test-session'); - assert.ok(fileEdit.beforeURI.startsWith(sessionDir.toString())); - assert.ok(fileEdit.afterURI.startsWith(sessionDir.toString())); + + // URIs are parseable session-db: URIs + const beforeFields = parseSessionDbUri(fileEdit.beforeURI); + assert.ok(beforeFields); + assert.strictEqual(beforeFields.sessionUri, 'copilot:/test-session'); + assert.strictEqual(beforeFields.toolCallId, 'tc-1'); + assert.strictEqual(beforeFields.filePath, '/workspace/test.txt'); + assert.strictEqual(beforeFields.part, 'before'); + + const afterFields = parseSessionDbUri(fileEdit.afterURI); + assert.ok(afterFields); + assert.strictEqual(afterFields.part, 'after'); + + // Content is persisted in the database (wait for fire-and-forget write) + await new Promise(r => setTimeout(r, 50)); + + const content = await db.readFileEditContent('tc-1', '/workspace/test.txt'); + assert.ok(content); + assert.strictEqual(new TextDecoder().decode(content.beforeContent), 'original content\nline 2'); + assert.strictEqual(new TextDecoder().decode(content.afterContent), 'modified content\nline 2\nline 3'); }); test('tracks edit for newly created file (no before content)', async () => { - const sourceFs = disposables.add(new InMemoryFileSystemProvider()); - disposables.add(fileService.registerProvider(Schemas.file, sourceFs)); - await tracker.trackEditStart('/workspace/new-file.txt'); await fileService.writeFile(URI.file('/workspace/new-file.txt'), VSBuffer.fromString('new file\ncontent')); await tracker.completeEdit('/workspace/new-file.txt'); - const fileEdit = tracker.takeCompletedEdit('/workspace/new-file.txt'); + const fileEdit = await tracker.takeCompletedEdit('turn-1', 'tc-2', '/workspace/new-file.txt'); assert.ok(fileEdit); - const sessionDir = sessionDataService.getSessionDataDirById('test-session'); - assert.ok(fileEdit.afterURI.startsWith(sessionDir.toString())); + + // Wait for the fire-and-forget DB write to complete + await new Promise(r => setTimeout(r, 50)); + + const content = await db.readFileEditContent('tc-2', '/workspace/new-file.txt'); + assert.ok(content); + assert.strictEqual(new TextDecoder().decode(content.beforeContent), ''); + assert.strictEqual(new TextDecoder().decode(content.afterContent), 'new file\ncontent'); }); - test('takeCompletedEdit returns undefined for unknown file path', () => { - const result = tracker.takeCompletedEdit('/nonexistent'); + test('takeCompletedEdit returns undefined for unknown file path', async () => { + const result = await tracker.takeCompletedEdit('turn-1', 'tc-x', '/nonexistent'); assert.strictEqual(result, undefined); }); - test('before and after snapshot content can be read back', async () => { - const sourceFs = disposables.add(new InMemoryFileSystemProvider()); - disposables.add(fileService.registerProvider(Schemas.file, sourceFs)); + test('before and after content can be read from database', async () => { await fileService.writeFile(URI.file('/workspace/file.ts'), VSBuffer.fromString('original')); await tracker.trackEditStart('/workspace/file.ts'); await fileService.writeFile(URI.file('/workspace/file.ts'), VSBuffer.fromString('modified')); await tracker.completeEdit('/workspace/file.ts'); - const fileEdit = tracker.takeCompletedEdit('/workspace/file.ts'); - assert.ok(fileEdit); - const beforeContent = await fileService.readFile(URI.parse(fileEdit.beforeURI)); - assert.strictEqual(beforeContent.value.toString(), 'original'); - const afterContent = await fileService.readFile(URI.parse(fileEdit.afterURI)); - assert.strictEqual(afterContent.value.toString(), 'modified'); + await tracker.takeCompletedEdit('turn-1', 'tc-3', '/workspace/file.ts'); + + // Wait for the fire-and-forget DB write to complete + await new Promise(r => setTimeout(r, 50)); + + const content = await db.readFileEditContent('tc-3', '/workspace/file.ts'); + assert.ok(content); + assert.strictEqual(new TextDecoder().decode(content.beforeContent), 'original'); + assert.strictEqual(new TextDecoder().decode(content.afterContent), 'modified'); + }); +}); + +suite('buildSessionDbUri / parseSessionDbUri', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('round-trips a simple URI', () => { + const uri = buildSessionDbUri('copilot:/abc-123', 'tc-1', '/workspace/file.ts', 'before'); + const parsed = parseSessionDbUri(uri); + assert.ok(parsed); + assert.deepStrictEqual(parsed, { + sessionUri: 'copilot:/abc-123', + toolCallId: 'tc-1', + filePath: '/workspace/file.ts', + part: 'before', + }); + }); + + test('round-trips with special characters in filePath', () => { + const uri = buildSessionDbUri('copilot:/s1', 'tc-2', '/work space/file (1).ts', 'after'); + const parsed = parseSessionDbUri(uri); + assert.ok(parsed); + assert.strictEqual(parsed.filePath, '/work space/file (1).ts'); + assert.strictEqual(parsed.part, 'after'); + }); + + test('round-trips with special characters in toolCallId', () => { + const uri = buildSessionDbUri('copilot:/s1', 'call_abc=123&x', '/file.ts', 'before'); + const parsed = parseSessionDbUri(uri); + assert.ok(parsed); + assert.strictEqual(parsed.toolCallId, 'call_abc=123&x'); + }); + + test('parseSessionDbUri returns undefined for non-session-db URIs', () => { + assert.strictEqual(parseSessionDbUri('file:///foo/bar'), undefined); + assert.strictEqual(parseSessionDbUri('https://example.com'), undefined); + }); + + test('parseSessionDbUri returns undefined for malformed session-db URIs', () => { + assert.strictEqual(parseSessionDbUri('session-db:copilot:/s1'), undefined); + assert.strictEqual(parseSessionDbUri('session-db:copilot:/s1?toolCallId=tc-1'), undefined); + assert.strictEqual(parseSessionDbUri('session-db:copilot:/s1?toolCallId=tc-1&filePath=/f&part=middle'), undefined); }); }); diff --git a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts new file mode 100644 index 0000000000000..9ebeb2fec2c66 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts @@ -0,0 +1,240 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { tmpdir } from 'os'; +import { randomUUID } from 'crypto'; +import { mkdirSync, rmSync } from 'fs'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { AgentSession } from '../../common/agentService.js'; +import { ToolResultContentType } from '../../common/state/sessionState.js'; +import { SessionDatabase } from '../../node/sessionDatabase.js'; +import { parseSessionDbUri } from '../../node/copilot/fileEditTracker.js'; +import { mapSessionEvents, type ISessionEvent } from '../../node/copilot/mapSessionEvents.js'; +import { join } from '../../../../base/common/path.js'; + +suite('mapSessionEvents', () => { + + const disposables = new DisposableStore(); + let testDir: string; + let db: SessionDatabase | undefined; + const session = AgentSession.uri('copilot', 'test-session'); + + setup(() => { + testDir = join(tmpdir(), `vscode-map-events-test-${randomUUID()}`); + mkdirSync(testDir, { recursive: true }); + }); + + teardown(async () => { + disposables.clear(); + await db?.close(); + rmSync(testDir, { recursive: true, force: true }); + }); + ensureNoDisposablesAreLeakedInTestSuite(); + + function dbPath(): string { + return join(testDir, 'session.db'); + } + + // ---- Basic event mapping -------------------------------------------- + + test('maps user and assistant messages', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', data: { messageId: 'msg-1', content: 'hello' } }, + { type: 'assistant.message', data: { messageId: 'msg-2', content: 'world' } }, + ]; + + const result = await mapSessionEvents(session, undefined, events); + assert.strictEqual(result.length, 2); + assert.deepStrictEqual(result[0], { + session, + type: 'message', + role: 'user', + messageId: 'msg-1', + content: 'hello', + toolRequests: undefined, + reasoningOpaque: undefined, + reasoningText: undefined, + encryptedContent: undefined, + parentToolCallId: undefined, + }); + assert.strictEqual(result[1].type, 'message'); + assert.strictEqual((result[1] as { role: string }).role, 'assistant'); + }); + + test('maps tool start and complete events', async () => { + const events: ISessionEvent[] = [ + { + type: 'tool.execution_start', + data: { toolCallId: 'tc-1', toolName: 'shell', arguments: { command: 'echo hi' } }, + }, + { + type: 'tool.execution_complete', + data: { toolCallId: 'tc-1', success: true, result: { content: 'hi\n' } }, + }, + ]; + + const result = await mapSessionEvents(session, undefined, events); + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].type, 'tool_start'); + assert.strictEqual(result[1].type, 'tool_complete'); + + const complete = result[1] as { result: { content?: readonly { type: string; text?: string }[] } }; + assert.ok(complete.result.content); + assert.strictEqual(complete.result.content[0].type, ToolResultContentType.Text); + }); + + test('skips tool_complete without matching tool_start', async () => { + const events: ISessionEvent[] = [ + { type: 'tool.execution_complete', data: { toolCallId: 'orphan', success: true } }, + ]; + + const result = await mapSessionEvents(session, undefined, events); + assert.strictEqual(result.length, 0); + }); + + test('ignores unknown event types', async () => { + const events: ISessionEvent[] = [ + { type: 'some.unknown.event', data: {} }, + { type: 'user.message', data: { messageId: 'msg-1', content: 'test' } }, + ]; + + const result = await mapSessionEvents(session, undefined, events); + assert.strictEqual(result.length, 1); + }); + + // ---- File edit restoration ------------------------------------------ + + suite('file edit restoration', () => { + + test('restores file edits from database for edit tools', async () => { + db = disposables.add(await SessionDatabase.open(dbPath())); + await db.createTurn('turn-1'); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-edit', + filePath: '/workspace/file.ts', + beforeContent: new TextEncoder().encode('before'), + afterContent: new TextEncoder().encode('after'), + addedLines: 3, + removedLines: 1, + }); + + const events: ISessionEvent[] = [ + { + type: 'tool.execution_start', + data: { toolCallId: 'tc-edit', toolName: 'edit', arguments: { filePath: '/workspace/file.ts' } }, + }, + { + type: 'tool.execution_complete', + data: { toolCallId: 'tc-edit', success: true, result: { content: 'Edited file.ts' } }, + }, + ]; + + const result = await mapSessionEvents(session, db, events); + const complete = result[1]; + assert.strictEqual(complete.type, 'tool_complete'); + + const content = (complete as { result: { content?: readonly Record[] } }).result.content; + assert.ok(content); + // Should have text content + file edit + assert.strictEqual(content.length, 2); + assert.strictEqual(content[0].type, ToolResultContentType.Text); + assert.strictEqual(content[1].type, ToolResultContentType.FileEdit); + + // File edit URIs should be parseable + const fileEdit = content[1] as { beforeURI: string; afterURI: string; diff?: { added?: number; removed?: number } }; + const beforeFields = parseSessionDbUri(fileEdit.beforeURI); + assert.ok(beforeFields); + assert.strictEqual(beforeFields.toolCallId, 'tc-edit'); + assert.strictEqual(beforeFields.filePath, '/workspace/file.ts'); + assert.strictEqual(beforeFields.part, 'before'); + assert.deepStrictEqual(fileEdit.diff, { added: 3, removed: 1 }); + }); + + test('handles multiple file edits for one tool call', async () => { + db = disposables.add(await SessionDatabase.open(dbPath())); + await db.createTurn('turn-1'); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-multi', + filePath: '/workspace/a.ts', + beforeContent: new Uint8Array(0), + afterContent: new TextEncoder().encode('a'), + addedLines: undefined, + removedLines: undefined, + }); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-multi', + filePath: '/workspace/b.ts', + beforeContent: new Uint8Array(0), + afterContent: new TextEncoder().encode('b'), + addedLines: undefined, + removedLines: undefined, + }); + + const events: ISessionEvent[] = [ + { + type: 'tool.execution_start', + data: { toolCallId: 'tc-multi', toolName: 'write' }, + }, + { + type: 'tool.execution_complete', + data: { toolCallId: 'tc-multi', success: true }, + }, + ]; + + const result = await mapSessionEvents(session, db, events); + const content = (result[1] as { result: { content?: readonly Record[] } }).result.content; + assert.ok(content); + // Two file edits (no text since result had no content) + const fileEdits = content.filter(c => c.type === ToolResultContentType.FileEdit); + assert.strictEqual(fileEdits.length, 2); + }); + + test('works without database (no file edits restored)', async () => { + const events: ISessionEvent[] = [ + { + type: 'tool.execution_start', + data: { toolCallId: 'tc-1', toolName: 'edit', arguments: { filePath: '/workspace/file.ts' } }, + }, + { + type: 'tool.execution_complete', + data: { toolCallId: 'tc-1', success: true, result: { content: 'done' } }, + }, + ]; + + const result = await mapSessionEvents(session, undefined, events); + const content = (result[1] as { result: { content?: readonly Record[] } }).result.content; + assert.ok(content); + // Only text content, no file edits + assert.strictEqual(content.length, 1); + assert.strictEqual(content[0].type, ToolResultContentType.Text); + }); + + test('non-edit tools do not get file edits even if db has data', async () => { + db = disposables.add(await SessionDatabase.open(dbPath())); + + const events: ISessionEvent[] = [ + { + type: 'tool.execution_start', + data: { toolCallId: 'tc-1', toolName: 'shell', arguments: { command: 'ls' } }, + }, + { + type: 'tool.execution_complete', + data: { toolCallId: 'tc-1', success: true, result: { content: 'files' } }, + }, + ]; + + const result = await mapSessionEvents(session, db, events); + const content = (result[1] as { result: { content?: readonly Record[] } }).result.content; + assert.ok(content); + assert.strictEqual(content.length, 1); + assert.strictEqual(content[0].type, ToolResultContentType.Text); + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/sessionDataService.test.ts b/src/vs/platform/agentHost/test/node/sessionDataService.test.ts index 5adefa25f084d..4faf0573777cc 100644 --- a/src/vs/platform/agentHost/test/node/sessionDataService.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDataService.test.ts @@ -4,16 +4,21 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { tmpdir } from 'os'; +import { randomUUID } from 'crypto'; +import { mkdirSync, rmSync } from 'fs'; import { VSBuffer } from '../../../../base/common/buffer.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { FileService } from '../../../files/common/fileService.js'; +import { DiskFileSystemProvider } from '../../../files/node/diskFileSystemProvider.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; import { AgentSession } from '../../common/agentService.js'; import { SessionDataService } from '../../node/sessionDataService.js'; +import { join } from '../../../../base/common/path.js'; suite('SessionDataService', () => { @@ -80,3 +85,78 @@ suite('SessionDataService', () => { await service.cleanupOrphanedData(new Set()); }); }); + +suite('SessionDataService — openDatabase ref-counting', () => { + + const disposables = new DisposableStore(); + let service: SessionDataService; + let testDir: string; + + setup(() => { + testDir = join(tmpdir(), `vscode-session-data-test-${randomUUID()}`); + mkdirSync(testDir, { recursive: true }); + + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new DiskFileSystemProvider(new NullLogService())))); + service = new SessionDataService(URI.file(testDir), fileService, new NullLogService()); + }); + + teardown(() => { + disposables.clear(); + rmSync(testDir, { recursive: true, force: true }); + }); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('returns a functional database reference', async () => { + const session = AgentSession.uri('copilot', 'ref-test'); + const ref = service.openDatabase(session); + disposables.add(ref); + + await ref.object.createTurn('turn-1'); + const edits = await ref.object.getFileEdits([]); + assert.deepStrictEqual(edits, []); + await ref.object.close(); + }); + + test('multiple references share the same database', async () => { + const session = AgentSession.uri('copilot', 'shared-test'); + const ref1 = service.openDatabase(session); + const ref2 = service.openDatabase(session); + + assert.strictEqual(ref1.object, ref2.object); + + ref1.dispose(); + ref2.dispose(); + await ref1.object.close(); + }); + + test('database remains usable until last reference is disposed', async () => { + const session = AgentSession.uri('copilot', 'refcount-test'); + const ref1 = service.openDatabase(session); + const ref2 = service.openDatabase(session); + + ref1.dispose(); + + // ref2 still works + await ref2.object.createTurn('turn-1'); + + ref2.dispose(); + + await ref1.object.close(); + }); + + test('new reference after all disposed gets a fresh database', async () => { + const session = AgentSession.uri('copilot', 'reopen-test'); + const ref1 = service.openDatabase(session); + const db1 = ref1.object; + ref1.dispose(); + + const ref2 = service.openDatabase(session); + disposables.add(ref2); + // New reference — may or may not be the same object, but must be functional + await ref2.object.createTurn('turn-1'); + assert.notStrictEqual(ref2.object, db1); + + await ref2.object.close(); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts new file mode 100644 index 0000000000000..8c4734b31c49f --- /dev/null +++ b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts @@ -0,0 +1,424 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { tmpdir } from 'os'; +import { randomUUID } from 'crypto'; +import { mkdirSync, rmSync } from 'fs'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { SessionDatabase, type ISessionDatabaseMigration } from '../../node/sessionDatabase.js'; +import { join } from '../../../../base/common/path.js'; + +suite('SessionDatabase', () => { + + const disposables = new DisposableStore(); + let testDir: string; + let db: SessionDatabase | undefined; + let db2: SessionDatabase | undefined; + + setup(() => { + testDir = join(tmpdir(), `vscode-session-db-test-${randomUUID()}`); + mkdirSync(testDir, { recursive: true }); + }); + + teardown(async () => { + disposables.clear(); + await Promise.all([db?.close(), db2?.close()]); + rmSync(testDir, { recursive: true, force: true }); + }); + ensureNoDisposablesAreLeakedInTestSuite(); + + function dbPath(name = 'session.db'): string { + return join(testDir, name); + } + + // ---- Migration system ----------------------------------------------- + + suite('migrations', () => { + + test('applies all migrations on a fresh database', async () => { + const migrations: ISessionDatabaseMigration[] = [ + { version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' }, + { version: 2, sql: 'CREATE TABLE t2 (id INTEGER PRIMARY KEY)' }, + ]; + + db = disposables.add(await SessionDatabase.open(dbPath(), migrations)); + + const tables = (await db.getAllTables()).sort(); + assert.deepStrictEqual(tables, ['t1', 't2']); + }); + + test('reopening with same migrations is a no-op', async () => { + const migrations: ISessionDatabaseMigration[] = [ + { version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' }, + ]; + + const db1 = await SessionDatabase.open(dbPath(), migrations); + await db1.close(); + + // Reopen — should not throw (table already exists, migration skipped) + db2 = disposables.add(await SessionDatabase.open(dbPath(), migrations)); + assert.deepStrictEqual(await db2.getAllTables(), ['t1']); + }); + + test('only applies new migrations on reopen', async () => { + const v1: ISessionDatabaseMigration[] = [ + { version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' }, + ]; + const db1 = await SessionDatabase.open(dbPath(), v1); + await db1.close(); + + const v2: ISessionDatabaseMigration[] = [ + ...v1, + { version: 2, sql: 'CREATE TABLE t2 (id INTEGER PRIMARY KEY)' }, + ]; + db2 = disposables.add(await SessionDatabase.open(dbPath(), v2)); + + const tables = (await db2.getAllTables()).sort(); + assert.deepStrictEqual(tables, ['t1', 't2']); + }); + + test('rolls back on migration failure', async () => { + const migrations: ISessionDatabaseMigration[] = [ + { version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' }, + { version: 2, sql: 'THIS IS INVALID SQL' }, + ]; + + await assert.rejects(() => SessionDatabase.open(dbPath(), migrations)); + + // Reopen with only v1 — t1 should not exist because the whole + // transaction was rolled back + db = disposables.add(await SessionDatabase.open(dbPath(), [ + { version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' }, + ])); + assert.deepStrictEqual(await db.getAllTables(), ['t1']); + }); + }); + + // ---- File edits ----------------------------------------------------- + + suite('file edits', () => { + + test('store and retrieve a file edit', async () => { + db = disposables.add(await SessionDatabase.open(dbPath())); + + await db.createTurn('turn-1'); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-1', + filePath: '/workspace/file.ts', + beforeContent: new TextEncoder().encode('before'), + afterContent: new TextEncoder().encode('after'), + addedLines: 5, + removedLines: 2, + }); + + const edits = await db.getFileEdits(['tc-1']); + assert.deepStrictEqual(edits, [{ + turnId: 'turn-1', + toolCallId: 'tc-1', + filePath: '/workspace/file.ts', + addedLines: 5, + removedLines: 2, + }]); + }); + + test('retrieve multiple edits for a single tool call', async () => { + db = disposables.add(await SessionDatabase.open(dbPath())); + + await db.createTurn('turn-1'); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-1', + filePath: '/workspace/a.ts', + beforeContent: new TextEncoder().encode('a-before'), + afterContent: new TextEncoder().encode('a-after'), + addedLines: undefined, + removedLines: undefined, + }); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-1', + filePath: '/workspace/b.ts', + beforeContent: new TextEncoder().encode('b-before'), + afterContent: new TextEncoder().encode('b-after'), + addedLines: 1, + removedLines: 0, + }); + + const edits = await db.getFileEdits(['tc-1']); + assert.strictEqual(edits.length, 2); + assert.strictEqual(edits[0].filePath, '/workspace/a.ts'); + assert.strictEqual(edits[1].filePath, '/workspace/b.ts'); + }); + + test('retrieve edits across multiple tool calls', async () => { + db = disposables.add(await SessionDatabase.open(dbPath())); + + await db.createTurn('turn-1'); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-1', + filePath: '/workspace/a.ts', + beforeContent: new Uint8Array(0), + afterContent: new TextEncoder().encode('hello'), + addedLines: undefined, + removedLines: undefined, + }); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-2', + filePath: '/workspace/b.ts', + beforeContent: new Uint8Array(0), + afterContent: new TextEncoder().encode('world'), + addedLines: undefined, + removedLines: undefined, + }); + + const edits = await db.getFileEdits(['tc-1', 'tc-2']); + assert.strictEqual(edits.length, 2); + + // Only tc-2 + const edits2 = await db.getFileEdits(['tc-2']); + assert.strictEqual(edits2.length, 1); + assert.strictEqual(edits2[0].toolCallId, 'tc-2'); + }); + + test('returns empty array for unknown tool call IDs', async () => { + db = disposables.add(await SessionDatabase.open(dbPath())); + const edits = await db.getFileEdits(['nonexistent']); + assert.deepStrictEqual(edits, []); + }); + + test('returns empty array when given empty array', async () => { + db = disposables.add(await SessionDatabase.open(dbPath())); + const edits = await db.getFileEdits([]); + assert.deepStrictEqual(edits, []); + }); + + test('replace on conflict (same toolCallId + filePath)', async () => { + db = disposables.add(await SessionDatabase.open(dbPath())); + + await db.createTurn('turn-1'); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-1', + filePath: '/workspace/file.ts', + beforeContent: new TextEncoder().encode('v1'), + afterContent: new TextEncoder().encode('v1-after'), + addedLines: 1, + removedLines: 0, + }); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-1', + filePath: '/workspace/file.ts', + beforeContent: new TextEncoder().encode('v2'), + afterContent: new TextEncoder().encode('v2-after'), + addedLines: 3, + removedLines: 1, + }); + + const edits = await db.getFileEdits(['tc-1']); + assert.strictEqual(edits.length, 1); + assert.strictEqual(edits[0].addedLines, 3); + + const content = await db.readFileEditContent('tc-1', '/workspace/file.ts'); + assert.ok(content); + assert.deepStrictEqual(new TextDecoder().decode(content.beforeContent), 'v2'); + }); + + test('readFileEditContent returns content on demand', async () => { + db = disposables.add(await SessionDatabase.open(dbPath())); + + await db.createTurn('turn-1'); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-1', + filePath: '/workspace/file.ts', + beforeContent: new TextEncoder().encode('before'), + afterContent: new TextEncoder().encode('after'), + addedLines: undefined, + removedLines: undefined, + }); + + const content = await db.readFileEditContent('tc-1', '/workspace/file.ts'); + assert.ok(content); + assert.deepStrictEqual(content.beforeContent, new TextEncoder().encode('before')); + assert.deepStrictEqual(content.afterContent, new TextEncoder().encode('after')); + }); + + test('readFileEditContent returns undefined for missing edit', async () => { + db = disposables.add(await SessionDatabase.open(dbPath())); + const content = await db.readFileEditContent('tc-missing', '/no/such/file'); + assert.strictEqual(content, undefined); + }); + + test('persists binary content correctly', async () => { + db = disposables.add(await SessionDatabase.open(dbPath())); + const binary = new Uint8Array([0, 1, 2, 255, 128, 64]); + + await db.createTurn('turn-1'); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-bin', + filePath: '/workspace/image.png', + beforeContent: new Uint8Array(0), + afterContent: binary, + addedLines: undefined, + removedLines: undefined, + }); + + const content = await db.readFileEditContent('tc-bin', '/workspace/image.png'); + assert.ok(content); + assert.deepStrictEqual(content.afterContent, binary); + }); + + test('auto-creates turn if it does not exist', async () => { + db = disposables.add(await SessionDatabase.open(dbPath())); + + // storeFileEdit should succeed even without a prior createTurn call + await db.storeFileEdit({ + turnId: 'auto-turn', + toolCallId: 'tc-1', + filePath: '/x', + beforeContent: new Uint8Array(0), + afterContent: new Uint8Array(0), + addedLines: undefined, + removedLines: undefined, + }); + + const edits = await db.getFileEdits(['tc-1']); + assert.strictEqual(edits.length, 1); + assert.strictEqual(edits[0].turnId, 'auto-turn'); + }); + }); + + // ---- Turns ---------------------------------------------------------- + + suite('turns', () => { + + test('createTurn is idempotent', async () => { + db = disposables.add(await SessionDatabase.open(dbPath())); + await db.createTurn('turn-1'); + await db.createTurn('turn-1'); // should not throw + }); + + test('deleteTurn cascades to file edits', async () => { + db = disposables.add(await SessionDatabase.open(dbPath())); + + await db.createTurn('turn-1'); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-1', + filePath: '/workspace/a.ts', + beforeContent: new TextEncoder().encode('before'), + afterContent: new TextEncoder().encode('after'), + addedLines: undefined, + removedLines: undefined, + }); + + // Edits exist + assert.strictEqual((await db.getFileEdits(['tc-1'])).length, 1); + + // Delete the turn — edits should be gone + await db.deleteTurn('turn-1'); + assert.deepStrictEqual(await db.getFileEdits(['tc-1']), []); + }); + + test('deleteTurn only removes its own edits', async () => { + db = disposables.add(await SessionDatabase.open(dbPath())); + + await db.createTurn('turn-1'); + await db.createTurn('turn-2'); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-1', + filePath: '/workspace/a.ts', + beforeContent: new Uint8Array(0), + afterContent: new TextEncoder().encode('a'), + addedLines: undefined, + removedLines: undefined, + }); + await db.storeFileEdit({ + turnId: 'turn-2', + toolCallId: 'tc-2', + filePath: '/workspace/b.ts', + beforeContent: new Uint8Array(0), + afterContent: new TextEncoder().encode('b'), + addedLines: undefined, + removedLines: undefined, + }); + + await db.deleteTurn('turn-1'); + + assert.deepStrictEqual(await db.getFileEdits(['tc-1']), []); + assert.strictEqual((await db.getFileEdits(['tc-2'])).length, 1); + }); + + test('deleteTurn is a no-op for unknown turn', async () => { + db = disposables.add(await SessionDatabase.open(dbPath())); + await db.deleteTurn('nonexistent'); // should not throw + }); + }); + + // ---- Dispose -------------------------------------------------------- + + suite('dispose', () => { + + test('methods throw after dispose', async () => { + db = await SessionDatabase.open(dbPath()); + db.close(); + + await assert.rejects( + () => db!.createTurn('turn-1'), + /disposed/, + ); + }); + + test('double dispose is safe', async () => { + db = await SessionDatabase.open(dbPath()); + await db.close(); + await db.close(); // should not throw + }); + }); + + // ---- Lazy open ------------------------------------------------------ + + suite('lazy open', () => { + + test('constructor does not open the database', () => { + // Should not throw even if path does not exist yet + db = new SessionDatabase(join(testDir, 'lazy', 'session.db')); + disposables.add(db); + // No error — the database is not opened until first use + }); + + test('first async call opens and migrates the database', async () => { + db = disposables.add(new SessionDatabase(dbPath())); + // Database file may not exist yet — first call triggers open + await db.createTurn('turn-1'); + const edits = await db.getFileEdits(['nonexistent']); + assert.deepStrictEqual(edits, []); + }); + + test('multiple concurrent calls share the same open promise', async () => { + db = disposables.add(new SessionDatabase(dbPath())); + // Fire multiple calls concurrently — all should succeed + await Promise.all([ + db.createTurn('turn-1'), + db.createTurn('turn-2'), + db.getFileEdits([]), + ]); + }); + + test('dispose during open rejects subsequent calls', async () => { + db = new SessionDatabase(dbPath()); + await db.close(); + await assert.rejects(() => db!.createTurn('turn-1'), /disposed/); + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/sessionStateManager.test.ts b/src/vs/platform/agentHost/test/node/sessionStateManager.test.ts index 852e8cf58fcbd..e1e7a08830f40 100644 --- a/src/vs/platform/agentHost/test/node/sessionStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionStateManager.test.ts @@ -121,7 +121,7 @@ suite('SessionStateManager', () => { assert.deepStrictEqual(envelopes[0].origin, origin); }); - test('removeSession clears state and emits notification', () => { + test('removeSession clears state without notification', () => { manager.createSession(makeSessionSummary()); const notifications: INotification[] = []; @@ -129,6 +129,19 @@ suite('SessionStateManager', () => { manager.removeSession(sessionUri); + assert.strictEqual(manager.getSessionState(sessionUri), undefined); + assert.strictEqual(manager.getSnapshot(sessionUri), undefined); + assert.strictEqual(notifications.length, 0); + }); + + test('deleteSession clears state and emits notification', () => { + manager.createSession(makeSessionSummary()); + + const notifications: INotification[] = []; + disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); + + manager.deleteSession(sessionUri); + assert.strictEqual(manager.getSessionState(sessionUri), undefined); assert.strictEqual(manager.getSnapshot(sessionUri), undefined); assert.strictEqual(notifications.length, 1); diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index c2f2161f6e000..ca9ad6d51f526 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -6,6 +6,7 @@ import { Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; import { UriComponents } from '../../../base/common/uri.js'; +import { IElementData } from '../../browserElements/common/browserElements.js'; import { localize } from '../../../nls.js'; const commandPrefix = 'workbench.action.browser'; @@ -59,7 +60,8 @@ export interface IBrowserViewBounds { export interface IBrowserViewCaptureScreenshotOptions { quality?: number; - rect?: { x: number; y: number; width: number; height: number }; + screenRect?: { x: number; y: number; width: number; height: number }; + pageRect?: { x: number; y: number; width: number; height: number }; } export interface IBrowserViewState { @@ -371,6 +373,36 @@ export interface IBrowserViewService { */ untrustCertificate(id: string, host: string, fingerprint: string): Promise; + /** + * Get captured console logs for a browser view. + * Console messages are automatically captured from the moment the view is created. + * @param id The browser view identifier + * @returns The captured console logs as a single string + */ + getConsoleLogs(id: string): Promise; + + /** + * Start element inspection mode in a browser view. Sets up a CDP overlay that + * highlights elements on hover. When the user clicks an element, its data is + * returned and the overlay is removed. + * @param id The browser view identifier + * @param cancellationId An identifier that can be passed to {@link cancel} to abort + * @returns The inspected element data, or undefined if cancelled + */ + getElementData(id: string, cancellationId: number): Promise; + + /** + * Get element data for the currently focused element in the browser view. + * @param id The browser view identifier + * @returns The focused element's data, or undefined if no element is focused + */ + getFocusedElementData(id: string): Promise; + + /** + * Cancel an in-progress request. + */ + cancel(cancellationId: number): Promise; + /** * Update the keybinding accelerators used in browser view context menus. * @param keybindings A map of command ID to accelerator label diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 8f61ab5cf8004..7e2dbd709df9f 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -7,7 +7,10 @@ import { WebContentsView, webContents } from 'electron'; import { Disposable } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; +import { CancellationToken } from '../../../base/common/cancellation.js'; import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewNewPageRequest, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, BrowserNewPageLocation, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex } from '../common/browserView.js'; +import { IElementData } from '../../browserElements/common/browserElements.js'; +import { getElementData, getFocusedElementData } from './browserViewElementInspector.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { ICodeWindow } from '../../window/electron-main/window.js'; import { IAuxiliaryWindowsMainService } from '../../auxiliaryWindow/electron-main/auxiliaryWindows.js'; @@ -38,6 +41,9 @@ export class BrowserView extends Disposable implements ICDPTarget { private _window: ICodeWindow | IAuxiliaryWindow | undefined; private _isDisposed = false; + private static readonly MAX_CONSOLE_LOG_ENTRIES = 1000; + private readonly _consoleLogs: string[] = []; + private readonly _onDidNavigate = this._register(new Emitter()); readonly onDidNavigate: Event = this._onDidNavigate.event; @@ -278,6 +284,7 @@ export class BrowserView extends Disposable implements ICDPTarget { // Chromium resets the zoom factor to its per-origin default (100%) when // navigating to a new document. Re-apply our stored zoom to override it. webContents.on('did-navigate', () => { + this._consoleLogs.length = 0; // Clear console logs on navigation since they are per-page this._view.webContents.setZoomFactor(browserZoomFactors[this._browserZoomIndex]); }); @@ -357,6 +364,14 @@ export class BrowserView extends Disposable implements ICDPTarget { finalUpdate: result.finalUpdate }); }); + + // Capture console messages for sharing with chat + this._view.webContents.on('console-message', (event) => { + this._consoleLogs.push(`[${event.level}] ${event.message}`); + if (this._consoleLogs.length > BrowserView.MAX_CONSOLE_LOG_ENTRIES) { + this._consoleLogs.splice(0, this._consoleLogs.length - BrowserView.MAX_CONSOLE_LOG_ENTRIES); + } + }); } private consumePopupPermission(location: BrowserNewPageLocation): boolean { @@ -456,6 +471,29 @@ export class BrowserView extends Disposable implements ICDPTarget { this._onDidChangeVisibility.fire({ visible }); } + /** + * Get captured console logs. + */ + getConsoleLogs(): string { + return this._consoleLogs.join('\n'); + } + + /** + * Start element inspection mode. Sets up a CDP overlay that highlights elements + * on hover. When the user clicks, the element data is returned and the overlay is removed. + * @param token Cancellation token to abort the inspection. + */ + async getElementData(token: CancellationToken): Promise { + return getElementData(this, token); + } + + /** + * Get element data for the currently focused element. + */ + async getFocusedElementData(): Promise { + return getFocusedElementData(this); + } + /** * Load a URL in this view */ @@ -518,13 +556,22 @@ export class BrowserView extends Disposable implements ICDPTarget { */ async captureScreenshot(options?: IBrowserViewCaptureScreenshotOptions): Promise { const quality = options?.quality ?? 80; - const image = await this._view.webContents.capturePage(options?.rect, { + if (options?.pageRect) { + const zoomFactor = this._view.webContents.getZoomFactor(); + options.screenRect = { + x: options.pageRect.x * zoomFactor, + y: options.pageRect.y * zoomFactor, + width: options.pageRect.width * zoomFactor, + height: options.pageRect.height * zoomFactor + }; + } + const image = await this._view.webContents.capturePage(options?.screenRect, { stayHidden: true }); const buffer = image.toJPEG(quality); const screenshot = VSBuffer.wrap(buffer); // Only update _lastScreenshot if capturing the full view - if (!options?.rect) { + if (!options?.screenRect) { this._lastScreenshot = screenshot; } return screenshot; diff --git a/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts b/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts new file mode 100644 index 0000000000000..0e11946daa8c3 --- /dev/null +++ b/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts @@ -0,0 +1,380 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { DisposableStore } from '../../../base/common/lifecycle.js'; +import { IElementData, IElementAncestor } from '../../browserElements/common/browserElements.js'; +import { ICDPConnection } from '../common/cdp/types.js'; +import type { BrowserView } from './browserView.js'; + +type Quad = [number, number, number, number, number, number, number, number]; + +interface IBoxModel { + content: Quad; + padding: Quad; + border: Quad; + margin: Quad; + width: number; + height: number; +} + +interface ICSSStyle { + cssText?: string; + cssProperties: Array<{ name: string; value: string }>; +} + +interface ISelectorList { + selectors: Array<{ text: string }>; +} + +interface ICSSRule { + selectorList: ISelectorList; + origin: string; + style: ICSSStyle; +} + +interface IRuleMatch { + rule: ICSSRule; +} + +interface IInheritedStyleEntry { + inlineStyle?: ICSSStyle; + matchedCSSRules: IRuleMatch[]; +} + +interface IMatchedStyles { + inlineStyle?: ICSSStyle; + matchedCSSRules?: IRuleMatch[]; + inherited?: IInheritedStyleEntry[]; +} + +interface INode { + nodeId: number; + backendNodeId: number; + parentId?: number; + localName: string; + attributes: string[]; + children?: INode[]; + pseudoElements?: INode[]; +} + +function useScopedDisposal() { + const store = new DisposableStore() as DisposableStore & { [Symbol.dispose](): void }; + store[Symbol.dispose] = () => store.dispose(); + return store; +} + +/** + * Start element inspection mode on a browser view. Sets up an + * overlay that highlights elements on hover. When the user clicks, the + * element data is returned and the overlay is removed. + * + * @param browser The browser view to inspect. + * @param token Cancellation token to abort the inspection. + */ +export async function getElementData(browser: BrowserView, token: CancellationToken): Promise { + using store = useScopedDisposal(); + + const connection = store.add(await browser.attach()); + + // Important: don't use `Runtime.*` commands in this flow so we can support element selection during debugging + await connection.sendCommand('DOM.enable'); + await connection.sendCommand('Overlay.enable'); + + store.add({ + dispose: async () => { + try { + await connection.sendCommand('Overlay.setInspectMode', { + mode: 'none', + highlightConfig: { + showInfo: false, + showStyles: false + } + }); + await connection.sendCommand('Overlay.hideHighlight'); + await connection.sendCommand('Overlay.disable'); + } catch { + // Best effort cleanup + } + } + }); + + const result = new Promise((resolve, reject) => { + store.add(token.onCancellationRequested(() => { + resolve(undefined); + })); + + store.add(connection.onEvent(async (event) => { + if (event.method !== 'Overlay.inspectNodeRequested') { + return; + } + + const params = event.params as { backendNodeId: number }; + if (!params?.backendNodeId) { + reject(new Error('Missing backendNodeId in inspectNodeRequested event')); + return; + } + + try { + const nodeData = await extractNodeData(connection, { backendNodeId: params.backendNodeId }); + resolve(nodeData); + } catch (err) { + reject(err); + } + })); + }); + + await connection.sendCommand('Overlay.setInspectMode', { + mode: 'searchForNode', + highlightConfig: inspectHighlightConfig, + }); + + return await result; +} + +/** + * Get element data for the currently focused element in a browser view. + * + * @param browser The browser view to inspect. + */ +export async function getFocusedElementData(browser: BrowserView): Promise { + using store = useScopedDisposal(); + + const connection = store.add(await browser.attach()); + await connection.sendCommand('Runtime.enable'); + + const { result } = await connection.sendCommand('Runtime.evaluate', { + expression: `document.activeElement`, + returnByValue: false, + }) as { result: { objectId?: string } }; + + if (!result?.objectId) { + return undefined; + } + + return extractNodeData(connection, { objectId: result.objectId }); +} + +// ---- private helpers -------------------------------------------------- + +async function extractNodeData(connection: ICDPConnection, id: { backendNodeId?: number; objectId?: string }): Promise { + using store = useScopedDisposal(); + + const discoveredNodesByNodeId: Record = {}; + store.add(connection.onEvent(event => { + if (event.method === 'DOM.setChildNodes') { + const { nodes } = event.params as { nodes: INode[] }; + for (const node of nodes) { + discoveredNodesByNodeId[node.nodeId] = node; + if (node.children) { + for (const child of node.children) { + discoveredNodesByNodeId[child.nodeId] = { + ...child, + parentId: node.nodeId + }; + } + } + if (node.pseudoElements) { + for (const pseudo of node.pseudoElements) { + discoveredNodesByNodeId[pseudo.nodeId] = { + ...pseudo, + parentId: node.nodeId + }; + } + } + } + } + })); + + await connection.sendCommand('DOM.enable'); + await connection.sendCommand('DOM.getDocument'); + await connection.sendCommand('CSS.enable'); + + const { node } = await connection.sendCommand('DOM.describeNode', id) as { node: INode }; + if (!node) { + throw new Error('Failed to describe node.'); + } + let nodeId = node.nodeId; + if (!nodeId) { + const { nodeIds } = await connection.sendCommand('DOM.pushNodesByBackendIdsToFrontend', { backendNodeIds: [node.backendNodeId] }) as { nodeIds: number[] }; + if (!nodeIds?.length) { + throw new Error('Failed to get node ID.'); + } + nodeId = nodeIds[0]; + } + + const { model } = await connection.sendCommand('DOM.getBoxModel', { nodeId }) as { model: IBoxModel }; + if (!model) { + throw new Error('Failed to get box model.'); + } + + const content = model.content; + const margin = model.margin; + const x = Math.min(margin[0], content[0]); + const y = Math.min(margin[1], content[1]); + const width = Math.max(margin[2] - margin[0], content[2] - content[0]); + const height = Math.max(margin[5] - margin[1], content[5] - content[1]); + + const matched = await connection.sendCommand('CSS.getMatchedStylesForNode', { nodeId }); + if (!matched) { + throw new Error('Failed to get matched css.'); + } + + const computedStyle = formatMatchedStyles(matched as IMatchedStyles); + const { outerHTML } = await connection.sendCommand('DOM.getOuterHTML', { nodeId }) as { outerHTML: string }; + if (!outerHTML) { + throw new Error('Failed to get outerHTML.'); + } + + const attributes = attributeArrayToRecord(node.attributes); + + let ancestors: IElementAncestor[] | undefined; + try { + ancestors = []; + let currentNode: INode | undefined = discoveredNodesByNodeId[nodeId]; + while (currentNode) { + const attributes = attributeArrayToRecord(currentNode.attributes); + ancestors.unshift({ + tagName: currentNode.localName, + id: attributes.id, + classNames: attributes.class?.trim().split(/\s+/).filter(Boolean) + }); + currentNode = currentNode.parentId ? discoveredNodesByNodeId[currentNode.parentId] : undefined; + } + } catch { } + + let computedStyles: Record | undefined; + try { + const { computedStyle: computedStyleArray } = await connection.sendCommand('CSS.getComputedStyleForNode', { nodeId }) as { computedStyle?: Array<{ name: string; value: string }> }; + if (computedStyleArray) { + computedStyles = {}; + for (const prop of computedStyleArray) { + if (prop.name && typeof prop.value === 'string') { + computedStyles[prop.name] = prop.value; + } + } + } + } catch { } + + return { + outerHTML, + computedStyle, + bounds: { x, y, width, height }, + ancestors, + attributes, + computedStyles, + dimensions: { top: y, left: x, width, height } + }; +} + +function formatMatchedStyles(matched: IMatchedStyles): string { + const lines: string[] = []; + + if (matched.inlineStyle?.cssProperties?.length) { + lines.push('/* Inline style */'); + lines.push('element {'); + for (const prop of matched.inlineStyle.cssProperties) { + if (prop.name && prop.value) { + lines.push(` ${prop.name}: ${prop.value};`); + } + } + lines.push('}\n'); + } + + if (matched.matchedCSSRules?.length) { + for (const ruleEntry of matched.matchedCSSRules) { + const rule = ruleEntry.rule; + const selectors = rule.selectorList.selectors.map((s: { text: string }) => s.text).join(', '); + lines.push(`/* Matched Rule from ${rule.origin} */`); + lines.push(`${selectors} {`); + for (const prop of rule.style.cssProperties) { + if (prop.name && prop.value) { + lines.push(` ${prop.name}: ${prop.value};`); + } + } + lines.push('}\n'); + } + } + + if (matched.inherited?.length) { + let level = 1; + for (const inherited of matched.inherited) { + if (inherited.inlineStyle) { + lines.push(`/* Inherited from ancestor level ${level} (inline) */`); + lines.push('element {'); + lines.push(inherited.inlineStyle.cssText || ''); + lines.push('}\n'); + } + + const rules = inherited.matchedCSSRules || []; + for (const ruleEntry of rules) { + const rule = ruleEntry.rule; + const selectors = rule.selectorList.selectors.map((s: { text: string }) => s.text).join(', '); + lines.push(`/* Inherited from ancestor level ${level} (${rule.origin}) */`); + lines.push(`${selectors} {`); + for (const prop of rule.style.cssProperties) { + if (prop.name && prop.value) { + lines.push(` ${prop.name}: ${prop.value};`); + } + } + lines.push('}\n'); + } + level++; + } + } + + return '\n' + lines.join('\n'); +} + +function attributeArrayToRecord(attributes: string[]): Record { + const record: Record = {}; + for (let i = 0; i < attributes.length; i += 2) { + const name = attributes[i]; + const value = attributes[i + 1]; + record[name] = value; + } + return record; +} + +/** Slightly customised CDP debugger inspect highlight colours. */ +const inspectHighlightConfig = { + showInfo: true, + showRulers: false, + showStyles: true, + showAccessibilityInfo: true, + showExtensionLines: false, + contrastAlgorithm: 'aa', + contentColor: { r: 173, g: 216, b: 255, a: 0.8 }, + paddingColor: { r: 150, g: 200, b: 255, a: 0.5 }, + borderColor: { r: 120, g: 180, b: 255, a: 0.7 }, + marginColor: { r: 200, g: 220, b: 255, a: 0.4 }, + eventTargetColor: { r: 130, g: 160, b: 255, a: 0.8 }, + shapeColor: { r: 130, g: 160, b: 255, a: 0.8 }, + shapeMarginColor: { r: 130, g: 160, b: 255, a: 0.5 }, + gridHighlightConfig: { + rowGapColor: { r: 140, g: 190, b: 255, a: 0.3 }, + rowHatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, + columnGapColor: { r: 140, g: 190, b: 255, a: 0.3 }, + columnHatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, + rowLineColor: { r: 120, g: 180, b: 255 }, + columnLineColor: { r: 120, g: 180, b: 255 }, + rowLineDash: true, + columnLineDash: true + }, + flexContainerHighlightConfig: { + containerBorder: { color: { r: 120, g: 180, b: 255 }, pattern: 'solid' }, + itemSeparator: { color: { r: 140, g: 190, b: 255 }, pattern: 'solid' }, + lineSeparator: { color: { r: 140, g: 190, b: 255 }, pattern: 'solid' }, + mainDistributedSpace: { hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, fillColor: { r: 140, g: 190, b: 255, a: 0.4 } }, + crossDistributedSpace: { hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, fillColor: { r: 140, g: 190, b: 255, a: 0.4 } }, + rowGapSpace: { hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, fillColor: { r: 140, g: 190, b: 255, a: 0.4 } }, + columnGapSpace: { hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, fillColor: { r: 140, g: 190, b: 255, a: 0.4 } }, + }, + flexItemHighlightConfig: { + baseSizeBox: { hatchColor: { r: 130, g: 170, b: 255, a: 0.6 } }, + baseSizeBorder: { color: { r: 120, g: 180, b: 255 }, pattern: 'solid' }, + flexibilityArrow: { color: { r: 130, g: 190, b: 255 } } + }, +}; diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index d7e9ac1737f57..150bfd6b6b47e 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -6,9 +6,10 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { VSBuffer } from '../../../base/common/buffer.js'; +import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { IBrowserViewBounds, IBrowserViewState, IBrowserViewService, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId } from '../common/browserView.js'; +import { IElementData } from '../../browserElements/common/browserElements.js'; import { clipboard, Menu, MenuItem } from 'electron'; -import { ICDPTarget, CDPBrowserVersion, CDPWindowBounds, CDPTargetInfo, ICDPConnection, ICDPBrowserTarget } from '../common/cdp/types.js'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; import { BrowserView } from './browserView.js'; @@ -21,11 +22,11 @@ import { IApplicationStorageMainService } from '../../storage/electron-main/stor import { CDPBrowserProxy } from '../common/cdp/proxy.js'; import { IntegratedBrowserOpenSource, logBrowserOpen } from '../common/browserViewTelemetry.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; -import { CancellationToken } from '../../../base/common/cancellation.js'; import { localize } from '../../../nls.js'; import { INativeHostMainService } from '../../native/electron-main/nativeHostMainService.js'; import { ITextEditorOptions } from '../../editor/common/editor.js'; import { htmlAttributeEncodeValue } from '../../../base/common/strings.js'; +import { ICDPTarget, CDPBrowserVersion, CDPWindowBounds, CDPTargetInfo, ICDPConnection, ICDPBrowserTarget } from '../common/cdp/types.js'; export const IBrowserViewMainService = createDecorator('browserViewMainService'); @@ -47,6 +48,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa } private readonly browserViews = this._register(new DisposableMap()); + private readonly _activeTokens = new Map(); private _keybindings: { [commandId: string]: string } = Object.create(null); // ICDPBrowserTarget events @@ -351,10 +353,37 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa await browserSession.clearData(); } + async getConsoleLogs(id: string): Promise { + return this._getBrowserView(id).getConsoleLogs(); + } + + async getElementData(id: string, cancellationId: number): Promise { + return this._makeCancellable(cancellationId, (token) => this._getBrowserView(id).getElementData(token)); + } + + async getFocusedElementData(id: string): Promise { + return this._getBrowserView(id).getFocusedElementData(); + } + + async cancel(cancellationId: number): Promise { + this._activeTokens.get(cancellationId)?.cancel(); + } + async updateKeybindings(keybindings: { [commandId: string]: string }): Promise { this._keybindings = keybindings; } + private async _makeCancellable(cancellationId: number, callback: (token: CancellationToken) => T | Promise): Promise { + const cts: CancellationTokenSource = new CancellationTokenSource(); + this._activeTokens.set(cancellationId, cts); + try { + return await callback(cts.token); + } finally { + this._activeTokens.delete(cancellationId); + cts.dispose(); + } + } + /** * Create a browser view backed by the given {@link BrowserSession}. */ diff --git a/src/vs/platform/terminal/common/terminal.ts b/src/vs/platform/terminal/common/terminal.ts index 15f1c7d1771fe..e9b508611266c 100644 --- a/src/vs/platform/terminal/common/terminal.ts +++ b/src/vs/platform/terminal/common/terminal.ts @@ -123,7 +123,6 @@ export const enum TerminalSettingId { EnableKittyKeyboardProtocol = 'terminal.integrated.enableKittyKeyboardProtocol', EnableWin32InputMode = 'terminal.integrated.enableWin32InputMode', AllowInUntrustedWorkspace = 'terminal.integrated.allowInUntrustedWorkspace', - EditorUseEditorBackground = 'terminal.integrated.editorUseEditorBackground', // Developer/debug settings diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index d8092909fb7a9..5c48d4391bbae 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -645,6 +645,7 @@ interface IPartVisibilityState { |------|--------| | 2026-03-26 | Updated the sessions sidebar appear animation so only the body content (`.part.sidebar > .content`) slides/fades in during reveal while the sidebar title/header and footer remain fixed | | 2026-03-25 | Updated Sessions view documentation to reflect the refactored `SessionsView` implementation in `contrib/sessions/browser/views/sessionsView.ts` and documented the left-aligned "+ Session" sidebar action with its inline keybinding hint | +| 2026-03-24 | Updated the sessions new-chat empty state: removed the watermark, vertically centered the empty-state controls block, restyled the workspace picker as an inline `New session in {dropdown}` title row aligned to the chat input, and tuned empty-state dropdown icon/chevron and local-mode spacing for the final visual polish. | | 2026-03-02 | Fixed macOS sidebar traffic light spacer to only render with custom titlebar; added `!hasNativeTitlebar()` guard to `SidebarPart.createTitleArea()` so the 70px spacer is not created when using native titlebar (traffic lights are in the OS title bar, not overlapping the sidebar) | | 2026-02-20 | Replaced custom `EditorModal` with standard `ModalEditorPart` via `MODAL_GROUP`; main editor part created but hidden; changed `workbench.editor.useModal` from boolean to enum (`off`/`some`/`all`); sessions config uses `all`; removed `editorModal.ts` and editor modal CSS | | 2026-02-17 | Added `-webkit-app-region: drag` to sidebar title area so it can be used to drag the window; interactive children (actions, composite bar, labels) marked `no-drag`; CSS rules scoped to `.agent-sessions-workbench` in `parts/media/sidebarPart.css` | diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index e789af76fee2f..b0c4e3f11e10a 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -242,6 +242,11 @@ /* ---- Widget Customizations ---- */ +/* Action Widget */ +.agent-sessions-workbench .action-widget .monaco-list .monaco-list-row { + padding-right: 0; +} + /* Badge */ .agent-sessions-workbench .badge > .badge-content { border-radius: 4px !important; diff --git a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts index 2fbf302209518..7f140d2fd7b9c 100644 --- a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts +++ b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts @@ -61,8 +61,8 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { private readonly _runScriptMenu = this._register(new MutableDisposable()); private readonly _runScriptMenuListener = this._register(new MutableDisposable()); - // Use the side bar dimensions - override readonly minimumWidth: number = 170; + // Sessions-specific auxiliary bar dimensions (intentionally not tied to the sessions SidebarPart values) + override readonly minimumWidth: number = 270; override readonly maximumWidth: number = Number.POSITIVE_INFINITY; override readonly minimumHeight: number = 0; override readonly maximumHeight: number = Number.POSITIVE_INFINITY; diff --git a/src/vs/sessions/browser/parts/sessionCompositeBar.ts b/src/vs/sessions/browser/parts/sessionCompositeBar.ts index 5238fef1b8ddc..d9e72bd9a9ef2 100644 --- a/src/vs/sessions/browser/parts/sessionCompositeBar.ts +++ b/src/vs/sessions/browser/parts/sessionCompositeBar.ts @@ -19,10 +19,10 @@ import { IQuickInputService } from '../../../platform/quickinput/common/quickInp // eslint-disable-next-line local/code-import-patterns import { ISessionsManagementService } from '../../contrib/sessions/browser/sessionsManagementService.js'; // eslint-disable-next-line local/code-import-patterns -import { IChatData } from '../../contrib/sessions/common/sessionData.js'; +import { IChat } from '../../contrib/sessions/common/sessionData.js'; interface ISessionTab { - readonly chat: IChatData; + readonly chat: IChat; readonly element: HTMLElement; } @@ -83,7 +83,7 @@ export class SessionCompositeBar extends Disposable { this._register(this._themeService.onDidColorThemeChange(() => this._updateStyles())); } - private _rebuildTabs(chats: readonly IChatData[], activeChatId: string, mainChatId?: string): void { + private _rebuildTabs(chats: readonly IChat[], activeChatId: string, mainChatId?: string): void { this._tabDisposables.clear(); this._tabs.length = 0; reset(this._tabsContainer); @@ -96,7 +96,7 @@ export class SessionCompositeBar extends Disposable { this._updateVisibility(); } - private _createTab(chat: IChatData, isMainChat: boolean): void { + private _createTab(chat: IChat, isMainChat: boolean): void { const tab = $('.session-composite-bar-tab'); tab.tabIndex = 0; tab.setAttribute('role', 'tab'); @@ -152,7 +152,7 @@ export class SessionCompositeBar extends Disposable { this._tabs.push({ chat: chat, element: tab }); } - private _onTabClicked(chat: IChatData): void { + private _onTabClicked(chat: IChat): void { this._sessionsManagementService.openChat(chat.resource); } diff --git a/src/vs/sessions/contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.ts b/src/vs/sessions/contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.ts index c3d198e1d161e..f379421612651 100644 --- a/src/vs/sessions/contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.ts +++ b/src/vs/sessions/contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.ts @@ -19,7 +19,6 @@ import { IProductService } from '../../../../platform/product/common/productServ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; -import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { URI } from '../../../../base/common/uri.js'; @@ -170,5 +169,5 @@ MenuRegistry.appendMenuItem(MenuId.ChatEditingSessionChangesToolbar, { title: localize2('applyActions', 'Apply Actions'), group: 'navigation', order: 1, - when: ContextKeyExpr.and(IsSessionsWindowContext, ChatContextKeys.hasAgentSessionChanges), + when: IsSessionsWindowContext, }); diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 137d2be768645..0e91f0af27478 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -93,6 +93,8 @@ const changesViewModeContextKey = new RawContextKey('changesVie // --- Versions Mode const enum ChangesVersionMode { + BranchChanges = 'branchChanges', + OutgoingChanges = 'outgoingChanges', AllChanges = 'allChanges', LastTurn = 'lastTurn' } @@ -102,7 +104,7 @@ const enum IsolationMode { Worktree = 'worktree' } -const changesVersionModeContextKey = new RawContextKey('sessions.changesVersionMode', ChangesVersionMode.AllChanges); +const changesVersionModeContextKey = new RawContextKey('sessions.changesVersionMode', ChangesVersionMode.BranchChanges); const isMergeBaseBranchProtectedContextKey = new RawContextKey('sessions.isMergeBaseBranchProtected', false); const isolationModeContextKey = new RawContextKey('sessions.isolationMode', IsolationMode.Workspace); const hasOpenPullRequestContextKey = new RawContextKey('sessions.hasOpenPullRequest', false); @@ -225,9 +227,14 @@ function buildTreeChildren(items: IChangesFileItem[]): IObjectTreeElement; readonly activeSessionResourceObs: IObservable; + readonly activeSessionBranchNameObs: IObservable; + readonly activeSessionBaseBranchNameObs: IObservable; + readonly activeSessionUpstreamBranchNameObs: IObservable; readonly activeSessionIsolationModeObs: IObservable; readonly activeSessionRepositoryObs: IObservableWithChange; readonly activeSessionChangesObs: IObservable; + readonly activeSessionFirstCheckpointRefObs: IObservable; + readonly activeSessionLastCheckpointRefObs: IObservable; readonly versionModeObs: ISettableObservable; setVersionMode(mode: ChangesVersionMode): void { @@ -264,14 +271,6 @@ class ChangesViewModel extends Disposable { return activeSession?.resource; }); - // Active session isolation mode - this.activeSessionIsolationModeObs = derived(reader => { - const activeSession = this.sessionManagementService.activeSession.read(reader); - return activeSession?.workspace.read(reader)?.repositories[0]?.workingDirectory === undefined - ? IsolationMode.Workspace - : IsolationMode.Worktree; - }); - // Active session changes this.activeSessionChangesObs = derivedOpts({ equalsFn: arrayEqualsC() @@ -283,6 +282,18 @@ class ChangesViewModel extends Disposable { return activeSession.changes.read(reader) as readonly (IChatSessionFileChange | IChatSessionFileChange2)[]; }); + const activeSessionRepositoryObs = derived(reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); + return activeSession?.workspace.read(reader)?.repositories[0]; + }); + + // Active session isolation mode + this.activeSessionIsolationModeObs = derived(reader => { + return activeSessionRepositoryObs.read(reader)?.workingDirectory === undefined + ? IsolationMode.Workspace + : IsolationMode.Worktree; + }); + // Active session repository const activeSessionRepositoryPromiseObs = derived(reader => { const activeSessionResource = this.activeSessionResourceObs.read(reader); @@ -290,13 +301,12 @@ class ChangesViewModel extends Disposable { return constObservable(undefined); } - const activeSession = this.sessionManagementService.activeSession.read(reader); - const worktree = activeSession?.workspace.read(reader)?.repositories[0]?.workingDirectory; - if (!worktree) { + const workingDirectory = activeSessionRepositoryObs.read(reader)?.workingDirectory; + if (!workingDirectory) { return constObservable(undefined); } - return new ObservablePromise(this.gitService.openRepository(worktree)).resolvedValue; + return new ObservablePromise(this.gitService.openRepository(workingDirectory)).resolvedValue; }); this.activeSessionRepositoryObs = derived(reader => { @@ -308,11 +318,57 @@ class ChangesViewModel extends Disposable { return activeSessionRepositoryPromise.read(reader); }); + // Active session branch name + this.activeSessionBranchNameObs = derived(reader => { + const repository = activeSessionRepositoryObs.read(reader); + const repositoryState = this.activeSessionRepositoryObs.read(reader)?.state.read(reader); + + return repository?.detail ?? repositoryState?.HEAD?.name; + }); + + // Active session base branch name + this.activeSessionBaseBranchNameObs = derived(reader => { + return activeSessionRepositoryObs.read(reader)?.baseBranchName; + }); + + // Active session upstream branch name + this.activeSessionUpstreamBranchNameObs = derived(reader => { + const repositoryState = this.activeSessionRepositoryObs.read(reader)?.state.read(reader); + return repositoryState?.HEAD?.upstream + ? `${repositoryState.HEAD.upstream.remote}/${repositoryState.HEAD.upstream.name}` + : undefined; + }); + + // Active session first checkpoint ref + this.activeSessionFirstCheckpointRefObs = derived(reader => { + const sessionResource = this.activeSessionResourceObs.read(reader); + if (!sessionResource) { + return undefined; + } + + this.sessionsChangedSignal.read(reader); + const model = this.agentSessionsService.getSession(sessionResource); + + return model?.metadata?.firstCheckpointRef as string | undefined; + }); + + // Active session last checkpoint ref + this.activeSessionLastCheckpointRefObs = derived(reader => { + const sessionResource = this.activeSessionResourceObs.read(reader); + if (!sessionResource) { + return undefined; + } + + this.sessionsChangedSignal.read(reader); + const model = this.agentSessionsService.getSession(sessionResource); + return model?.metadata?.lastCheckpointRef as string | undefined; + }); + // Version mode - this.versionModeObs = observableValue(this, ChangesVersionMode.AllChanges); + this.versionModeObs = observableValue(this, ChangesVersionMode.BranchChanges); this._register(runOnChange(this.activeSessionResourceObs, () => { - this.setVersionMode(ChangesVersionMode.AllChanges); + this.setVersionMode(ChangesVersionMode.BranchChanges); })); // View mode @@ -405,13 +461,6 @@ export class ChangesViewPane extends ViewPane { this.bodyContainer = dom.append(container, $('.changes-view-body')); - // Welcome message for empty state - this.welcomeContainer = dom.append(this.bodyContainer, $('.changes-welcome')); - const welcomeIcon = dom.append(this.welcomeContainer, $('.changes-welcome-icon')); - welcomeIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.diffMultiple)); - const welcomeMessage = dom.append(this.welcomeContainer, $('.changes-welcome-message')); - welcomeMessage.textContent = localize('changesView.noChanges', "No files have been changed."); - // Actions container - positioned outside and above the card this.actionsContainer = dom.append(this.bodyContainer, $('.chat-editing-session-actions.outside-card')); @@ -457,6 +506,13 @@ export class ChangesViewPane extends ViewPane { // List container this.listContainer = dom.append(this.contentContainer, $('.chat-editing-session-list')); + // Welcome message for empty state + this.welcomeContainer = dom.append(this.contentContainer, $('.changes-welcome')); + const welcomeIcon = dom.append(this.welcomeContainer, $('.changes-welcome-icon')); + welcomeIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.diffMultiple)); + const welcomeMessage = dom.append(this.welcomeContainer, $('.changes-welcome-message')); + welcomeMessage.textContent = localize('changesView.noChanges', "Changed files and other session artifacts will appear here."); + // CI Status widget — bottom pane this.ciStatusWidget = this._register(this.instantiationService.createInstance(CIStatusWidget, this.splitViewContainer)); @@ -632,18 +688,6 @@ export class ChangesViewPane extends ViewPane { return repository?.state.read(reader)?.HEAD?.commit; }); - const lastCheckpointRefObs = derived(reader => { - const sessionResource = this.viewModel.activeSessionResourceObs.read(reader); - if (!sessionResource) { - return undefined; - } - - this.viewModel.sessionsChangedSignal.read(reader); - const model = this.agentSessionsService.getSession(sessionResource); - - return model?.metadata?.lastCheckpointRef as string | undefined; - }); - const lastTurnChangesObs = derived(reader => { const repository = this.viewModel.activeSessionRepositoryObs.read(reader); const headCommit = headCommitObs.read(reader); @@ -652,7 +696,7 @@ export class ChangesViewPane extends ViewPane { return constObservable(undefined); } - const lastCheckpointRef = lastCheckpointRefObs.read(reader); + const lastCheckpointRef = this.viewModel.activeSessionLastCheckpointRefObs.read(reader); const diffPromise = lastCheckpointRef ? repository.diffBetweenWithStats(`${lastCheckpointRef}^`, lastCheckpointRef) @@ -694,8 +738,8 @@ export class ChangesViewPane extends ViewPane { let sourceEntries: IChangesFileItem[]; if (versionMode === ChangesVersionMode.LastTurn) { - const lastCheckpointRef = lastCheckpointRefObs.read(reader); const lastTurnDiffChanges = lastTurnChangesObs.read(reader).read(reader); + const lastCheckpointRef = this.viewModel.activeSessionLastCheckpointRefObs.read(reader); const diffChanges = lastTurnDiffChanges ?? []; @@ -915,9 +959,7 @@ export class ChangesViewPane extends ViewPane { const { files } = topLevelStats.read(reader); const hasEntries = files > 0; - dom.setVisibility(hasEntries, this.contentContainer!); - dom.setVisibility(hasEntries, this.actionsContainer!); - dom.setVisibility(hasEntries, this.splitViewContainer!); + dom.setVisibility(hasEntries, this.listContainer!); dom.setVisibility(!hasEntries, this.welcomeContainer!); if (this.filesCountBadge) { @@ -952,7 +994,7 @@ export class ChangesViewPane extends ViewPane { const resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this.onDidChangeBodyVisibility })); const actionRunner = this.renderDisposables.add(new ChangesViewActionRunner( () => this.viewModel.activeSessionResourceObs.get(), - () => this.getSessionRefs(), + () => this.getSessionDiscardRef(), () => this.getTreeSelection(), )); this.tree = this.instantiationService.createInstance( @@ -1155,26 +1197,16 @@ export class ChangesViewPane extends ViewPane { return selection.filter(item => !!item && isChangesFileItem(item)); } - private getSessionRefs(): [string, string] { - const activeSession = this.sessionManagementService.activeSession.get(); - const activeSessionIsolationMode = this.viewModel.activeSessionIsolationModeObs.get(); - const activeSessionRepositoryState = this.viewModel.activeSessionRepositoryObs.get()?.state.get(); + private getSessionDiscardRef(): string { + const versionMode = this.viewModel.versionModeObs.get(); + const firstCheckpointRef = this.viewModel.activeSessionFirstCheckpointRefObs.get(); + const lastCheckpointRef = this.viewModel.activeSessionLastCheckpointRefObs.get(); - let originalRef: string, modifiedRef: string; - if (activeSessionIsolationMode === IsolationMode.Worktree) { - // Worktree - originalRef = activeSession?.workspace.get()?.repositories[0].baseBranchName ?? ''; - modifiedRef = activeSessionRepositoryState?.HEAD?.name ?? ''; - } else { - // Workspace - const upstream = activeSessionRepositoryState?.HEAD?.upstream; - originalRef = upstream - ? `${upstream.remote}/${upstream.name}` - : activeSessionRepositoryState?.HEAD?.name ?? ''; - modifiedRef = activeSessionRepositoryState?.HEAD?.name ?? ''; - } - - return [originalRef, modifiedRef]; + return versionMode === ChangesVersionMode.LastTurn + ? lastCheckpointRef + ? `${lastCheckpointRef}^` + : '' + : firstCheckpointRef ?? ''; } protected override layoutBody(height: number, width: number): void { @@ -1225,7 +1257,7 @@ class ChangesViewActionRunner extends ActionRunner { constructor( private readonly getSessionResource: () => URI | undefined, - private readonly getSessionRefs: () => [originalRef: string, modifiedRef: string], + private readonly getSessionDiscardRef: () => string, private readonly getSelectedFileItems: () => IChangesFileItem[] ) { super(); @@ -1237,11 +1269,11 @@ class ChangesViewActionRunner extends ActionRunner { } const sessionResource = this.getSessionResource(); - const [originalRef, modifiedRef] = this.getSessionRefs(); + const discardRef = this.getSessionDiscardRef(); const selection = this.getSelectedFileItems(); const contextIsSelected = selection.some(s => isEqual(s.uri, context)); const actualContext = contextIsSelected ? selection.map(s => s.uri) : [context]; - await action.run(sessionResource, originalRef, modifiedRef, ...actualContext); + await action.run(sessionResource, discardRef, ...actualContext); } } @@ -1539,31 +1571,33 @@ class ChangesPickerActionItem extends ActionWidgetDropdownActionViewItem { ) { const actionProvider: IActionWidgetDropdownActionProvider = { getActions: () => { - const activeSession = sessionManagementService.activeSession.get(); - const activeSessionIsolationMode = this.viewModel.activeSessionIsolationModeObs.get(); - const activeSessionRepositoryState = this.viewModel.activeSessionRepositoryObs.get()?.state.get(); - const activeSessionRepository = activeSession?.workspace.get()?.repositories[0]; - - const baseBranchName = activeSessionIsolationMode === IsolationMode.Worktree - ? activeSessionRepository?.baseBranchName ?? '' - : activeSessionRepositoryState?.HEAD?.upstream - ? `${activeSessionRepositoryState.HEAD.upstream.remote}/${activeSessionRepositoryState.HEAD.upstream.name}` - : activeSessionRepositoryState?.HEAD?.name ?? ''; - - const branchName = activeSessionRepository?.detail - ?? activeSessionRepositoryState?.HEAD?.name ?? ''; - - const allChangesDescription = baseBranchName && branchName - ? `${branchName} → ${baseBranchName}` - : branchName ?? localize('chatEditing.versionsAllChanges.description', 'Show all changes made in this session'); + const branchName = viewModel.activeSessionBranchNameObs.get(); + const baseBranchName = viewModel.activeSessionBaseBranchNameObs.get(); return [ + { + ...action, + id: 'chatEditing.versionsBranchChanges', + label: localize('chatEditing.versionsBranchChanges', 'Branch Changes'), + description: `${branchName} → ${baseBranchName}`, + checked: viewModel.versionModeObs.get() === ChangesVersionMode.BranchChanges, + category: { label: 'changes', order: 1, showHeader: false }, + run: async () => { + viewModel.setVersionMode(ChangesVersionMode.BranchChanges); + if (this.element) { + this.renderLabel(this.element); + } + }, + }, { ...action, id: 'chatEditing.versionsAllChanges', label: localize('chatEditing.versionsAllChanges', 'All Changes'), - description: allChangesDescription, + description: localize('chatEditing.versionsAllChanges.description', 'Show all changes made in this session'), checked: viewModel.versionModeObs.get() === ChangesVersionMode.AllChanges, + category: { label: 'checkpoints', order: 2, showHeader: false }, + enabled: viewModel.activeSessionFirstCheckpointRefObs.get() !== undefined && + viewModel.activeSessionLastCheckpointRefObs.get() !== undefined, run: async () => { viewModel.setVersionMode(ChangesVersionMode.AllChanges); if (this.element) { @@ -1577,6 +1611,9 @@ class ChangesPickerActionItem extends ActionWidgetDropdownActionViewItem { label: localize('chatEditing.versionsLastTurnChanges', "Last Turn's Changes"), description: localize('chatEditing.versionsLastTurnChanges.description', 'Show only changes from the last turn'), checked: viewModel.versionModeObs.get() === ChangesVersionMode.LastTurn, + category: { label: 'checkpoints', order: 3, showHeader: false }, + enabled: viewModel.activeSessionFirstCheckpointRefObs.get() !== undefined && + viewModel.activeSessionLastCheckpointRefObs.get() !== undefined, run: async () => { viewModel.setVersionMode(ChangesVersionMode.LastTurn); if (this.element) { @@ -1600,9 +1637,11 @@ class ChangesPickerActionItem extends ActionWidgetDropdownActionViewItem { protected override renderLabel(element: HTMLElement): null { const mode = this.viewModel.versionModeObs.get(); - const label = mode === ChangesVersionMode.LastTurn - ? localize('sessionsChanges.versionsLastTurn', "Last Turn's Changes") - : localize('sessionsChanges.versionsAllChanges', "All Changes"); + const label = mode === ChangesVersionMode.BranchChanges + ? localize('sessionsChanges.versionsBranchChanges', "Branch Changes") + : mode === ChangesVersionMode.AllChanges + ? localize('sessionsChanges.versionsAllChanges', "All Changes") + : localize('sessionsChanges.versionsLastTurn', "Last Turn's Changes"); dom.reset(element, dom.$('span', undefined, label), ...renderLabelWithIcons('$(chevron-down)')); this.updateAriaLabel(); diff --git a/src/vs/sessions/contrib/changes/browser/media/changesView.css b/src/vs/sessions/contrib/changes/browser/media/changesView.css index dd7481b39bae8..13cfef8a6cbf7 100644 --- a/src/vs/sessions/contrib/changes/browser/media/changesView.css +++ b/src/vs/sessions/contrib/changes/browser/media/changesView.css @@ -30,15 +30,15 @@ align-items: center; justify-content: center; flex: 1; - padding: 20px; + padding: 32px; text-align: center; gap: 8px; } .changes-view-body .changes-welcome-icon.codicon { - font-size: 48px !important; + font-size: 32px !important; color: var(--vscode-descriptionForeground); - opacity: 0.6; + opacity: 0.4; } .changes-view-body .changes-welcome-message { @@ -74,6 +74,10 @@ font-size: 12px; align-items: center; + > span { + margin-left: 2px; + } + > .codicon { font-size: 10px !important; padding-left: 4px; @@ -87,8 +91,8 @@ font-size: 11px; padding: 2px 0; border-radius: 4px; - background-color: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); + background-color: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); + color: var(--vscode-descriptionForeground); line-height: 1; font-weight: 600; min-width: 16px; @@ -179,7 +183,7 @@ } .changes-view-body .chat-editing-session-actions.outside-card .monaco-button.secondary.monaco-text-button.codicon { - padding: 4px 8px; + padding: 4px 6px; font-size: 16px !important; } diff --git a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts index 3756ed8a8efdf..886add0254b0e 100644 --- a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts +++ b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts @@ -19,6 +19,7 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { localize } from '../../../../nls.js'; +import { AGENT_HOST_SCHEME } from '../../../../platform/agentHost/common/agentHostUri.js'; /** * Agent Sessions override of IAICustomizationWorkspaceService. @@ -61,7 +62,11 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization } const session = this.sessionsService.activeSession.read(reader); const repo = session?.workspace.read(reader)?.repositories[0]; - return repo?.workingDirectory ?? repo?.uri; + const root = repo?.workingDirectory ?? repo?.uri; + if (root?.scheme === AGENT_HOST_SCHEME) { + return undefined; + } + return root; }); this.hasOverrideProjectRoot = derived(reader => { @@ -76,7 +81,11 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization } const session = this.sessionsService.activeSession.get(); const repo = session?.workspace.get()?.repositories[0]; - return repo?.workingDirectory ?? repo?.uri; + const root = repo?.workingDirectory ?? repo?.uri; + if (root?.scheme === AGENT_HOST_SCHEME) { + return undefined; + } + return root; } setOverrideProjectRoot(root: URI): void { diff --git a/src/vs/sessions/contrib/chat/browser/extensionToolbarPickers.ts b/src/vs/sessions/contrib/chat/browser/extensionToolbarPickers.ts index 34dab27ff2f7c..6ed3fe1115573 100644 --- a/src/vs/sessions/contrib/chat/browser/extensionToolbarPickers.ts +++ b/src/vs/sessions/contrib/chat/browser/extensionToolbarPickers.ts @@ -16,6 +16,7 @@ import { IChatSessionProviderOptionItem } from '../../../../workbench/contrib/ch import { ISessionOptionGroup } from './newSession.js'; import { RemoteNewSession } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.js'; /** * Self-contained widget that renders extension-driven toolbar pickers @@ -35,6 +36,7 @@ export class ExtensionToolbarPickers extends Disposable { @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, ) { super(); @@ -60,15 +62,16 @@ export class ExtensionToolbarPickers extends Disposable { } private _bindToSession(): void { - const chat = this.sessionsManagementService.activeSession.get()?.activeChat.get(); - if (!chat) { + const session = this.sessionsManagementService.activeSession.get(); + if (!session) { return; } - if (chat instanceof RemoteNewSession) { - this._renderToolbarPickers(chat, true); - this._sessionDisposables.add(chat.onDidChangeOptionGroups(() => { - this._renderToolbarPickers(chat); + const providerSession = this.sessionsProvidersService.getUntitledSession(session.providerId); + if (providerSession instanceof RemoteNewSession) { + this._renderToolbarPickers(providerSession, true); + this._sessionDisposables.add(providerSession.onDidChangeOptionGroups(() => { + this._renderToolbarPickers(providerSession); })); } } diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css index 1a3017045c481..272eb3d02a837 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css @@ -12,48 +12,15 @@ height: 100%; box-sizing: border-box; overflow: hidden; - padding: 0 10px 48px 10px; + padding: 16px 16px 20px 16px; container-type: size; + position: relative; } .chat-full-welcome.revealed { justify-content: center; } -/* Header */ -.chat-full-welcome-header { - display: flex; - flex-direction: column; - align-items: center; - width: 100%; - max-width: 800px; - overflow: visible; -} - -/* Watermark letterpress */ -.chat-full-welcome-letterpress { - width: 100%; - max-width: 200px; - aspect-ratio: 1/1; - background-image: url('./letterpress-sessions-dark.svg'); - background-size: contain; - background-position: center; - background-repeat: no-repeat; - margin-top: 8px; - margin-bottom: 20px; -} - -.vs .chat-full-welcome-letterpress, -.hc-light .chat-full-welcome-letterpress { - background-image: url('./letterpress-sessions-light.svg'); -} - -@container (max-height: 350px) { - .chat-full-welcome-letterpress { - display: none; - } -} - /* Input slot */ .chat-full-welcome-inputSlot { width: 100%; @@ -73,7 +40,7 @@ display: none; width: 100%; max-width: 800px; - margin: 0 0 24px 0; + margin: 0 0 12px 0; padding: 0; box-sizing: border-box; } @@ -100,13 +67,19 @@ margin-bottom: 0; } +.chat-full-welcome-content { + width: 100%; + max-width: 800px; + display: flex; + flex-direction: column; + align-items: stretch; +} + /* Local mode picker (Workspace / Worktree) below input */ .chat-full-welcome-local-mode { width: 100%; max-width: 800px; margin-top: 8px; - padding-left: 7px; - padding-right: 7px; box-sizing: border-box; display: none; flex-direction: row; @@ -167,7 +140,8 @@ flex-direction: row; flex-wrap: nowrap; align-items: center; - justify-content: center; + justify-content: flex-start; + gap: 6px; width: 100%; box-sizing: border-box; padding: 0; @@ -177,34 +151,46 @@ display: none; } -/* Prominent project picker button */ +.chat-full-welcome-pickers-label { + font-size: 18px; + line-height: 1.25; + color: var(--vscode-descriptionForeground); + white-space: nowrap; +} + +/* Project picker in inline title row */ .sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label { height: auto; - padding: 8px 20px; - font-size: 15px; - border: 1px solid var(--vscode-button-border); - background-color: var(--vscode-button-secondaryBackground); - color: var(--vscode-button-secondaryForeground); - border-radius: 6px; + padding: 4px; + font-size: 18px; + line-height: 1.25; + border: none; + background-color: transparent; + color: var(--vscode-foreground); + border-radius: 4px; } .sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label:hover { - background-color: var(--vscode-button-secondaryHoverBackground); + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); } -.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label .codicon { +.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label .sessions-chat-dropdown-label { font-size: 18px; - margin-right: 2px; } -.sessions-chat-picker-slot.sessions-chat-project-picker .action-label .codicon-chevron-down { - margin-right: 0; - position: relative; - top: 1px; +.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label > .codicon:not(.sessions-chat-dropdown-chevron) { + font-size: 16px; } -.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label .sessions-chat-dropdown-label { - font-size: 15px; +.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label .sessions-chat-dropdown-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 16px; + margin-left: 6px; + line-height: 1; + transform: translateY(1px); } .sessions-chat-dropdown-label { @@ -290,8 +276,13 @@ } .sessions-chat-picker-slot .action-label .codicon-chevron-down { + display: inline-flex; + align-items: center; + justify-content: center; font-size: 12px; margin-left: 6px; + line-height: 1; + transform: translateY(1px); } .sessions-chat-picker-slot .action-label .chat-session-option-label { diff --git a/src/vs/sessions/contrib/chat/browser/newChatPermissionPicker.ts b/src/vs/sessions/contrib/chat/browser/newChatPermissionPicker.ts index 365f4747b3a19..fcabc2bfefc92 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatPermissionPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatPermissionPicker.ts @@ -13,6 +13,7 @@ import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListOp import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.js'; import { CopilotCLISession } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; @@ -60,16 +61,18 @@ export class NewChatPermissionPicker extends Disposable { @IDialogService private readonly dialogService: IDialogService, @IOpenerService private readonly openerService: IOpenerService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, ) { super(); // Write permission level to the active session data when it changes this._register(this.onDidChangeLevel(level => { - const chat = this.sessionsManagementService.activeSession.get()?.activeChat.get(); - if (!(chat instanceof CopilotCLISession)) { + const session = this.sessionsManagementService.activeSession.get(); + const providerSession = session ? this.sessionsProvidersService.getUntitledSession(session.providerId) : undefined; + if (!(providerSession instanceof CopilotCLISession)) { throw new Error('NewChatPermissionPicker requires a CopilotCLISession'); } - chat.setPermissionLevel(level); + providerSession.setPermissionLevel(level); })); } diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index dd1d36bc81332..3a0b7aa2419d9 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -138,6 +138,9 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._sessionTypePicker = this._register(this.instantiationService.createInstance(SessionTypePicker)); // When a workspace is selected, create a new session + this._register(this._workspacePicker.onDidChangeSelection(() => { + this._renderOptionGroupPickers(); + })); this._register(this._workspacePicker.onDidSelectWorkspace(async (workspace) => { await this._onWorkspaceSelected(workspace); this._focusEditor(); @@ -168,15 +171,14 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { const welcomeElement = dom.append(wrapper, dom.$('.chat-full-welcome')); - // Watermark letterpress - const header = dom.append(welcomeElement, dom.$('.chat-full-welcome-header')); - dom.append(header, dom.$('.chat-full-welcome-letterpress')); + // Main empty-state content area (folder picker, input, local mode controls) + const welcomeContent = dom.append(welcomeElement, dom.$('.chat-full-welcome-content')); // Option group pickers (above the input) - this._pickersContainer = dom.append(welcomeElement, dom.$('.chat-full-welcome-pickers-container')); + this._pickersContainer = dom.append(welcomeContent, dom.$('.chat-full-welcome-pickers-container')); // Input slot - this._inputSlot = dom.append(welcomeElement, dom.$('.chat-full-welcome-inputSlot')); + this._inputSlot = dom.append(welcomeContent, dom.$('.chat-full-welcome-inputSlot')); // Input area inside the input slot const inputArea = dom.$('.sessions-chat-input-area'); @@ -193,7 +195,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._inputSlot.appendChild(inputArea); // Below-input row: session type picker, permission control, spacer, repository config (right) - const belowInputRow = dom.append(welcomeElement, dom.$('.chat-full-welcome-local-mode')); + const belowInputRow = dom.append(welcomeContent, dom.$('.chat-full-welcome-local-mode')); this._sessionTypePicker.render(belowInputRow); const controlContainer = dom.append(belowInputRow, dom.$('.sessions-chat-control-toolbar')); this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, controlContainer, Menus.NewSessionControl, { @@ -438,6 +440,10 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { dom.clearNode(this._pickersContainer); const pickersRow = dom.append(this._pickersContainer, dom.$('.chat-full-welcome-pickers')); + const pickersLabel = dom.append(pickersRow, dom.$('.chat-full-welcome-pickers-label')); + pickersLabel.textContent = this._workspacePicker.selectedProject + ? localize('newSessionIn', "New session in") + : localize('newSessionChooseWorkspace', "Start by picking a"); // Project picker (unified folder + repo picker) this._workspacePicker.render(pickersRow); diff --git a/src/vs/sessions/contrib/chat/browser/promptsService.ts b/src/vs/sessions/contrib/chat/browser/promptsService.ts index bcce55f35dc19..b77212599ea4a 100644 --- a/src/vs/sessions/contrib/chat/browser/promptsService.ts +++ b/src/vs/sessions/contrib/chat/browser/promptsService.ts @@ -38,8 +38,7 @@ export class AgenticPromptsService extends PromptsService { private getCopilotRoot(): URI { if (!this._copilotRoot) { - const pathService = this.instantiationService.invokeFunction(accessor => accessor.get(IPathService)); - this._copilotRoot = joinPath(pathService.userHome({ preferLocal: true }), '.copilot'); + this._copilotRoot = joinPath(this.pathService.userHome({ preferLocal: true }), '.copilot'); } return this._copilotRoot; } @@ -62,9 +61,8 @@ export class AgenticPromptsService extends PromptsService { * Each subdirectory containing a SKILL.md is treated as a skill. */ private async discoverBuiltinSkills(): Promise { - const fileService = this.instantiationService.invokeFunction(accessor => accessor.get(IFileService)); try { - const stat = await fileService.resolve(BUILTIN_SKILLS_URI); + const stat = await this.fileService.resolve(BUILTIN_SKILLS_URI); if (!stat.children) { return []; } diff --git a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts index 9e15ce6b4a61b..032729f1671cc 100644 --- a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts +++ b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts @@ -26,17 +26,14 @@ import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keyb import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; -import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; -import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { SessionsCategories } from '../../../common/categories.js'; import { IsActiveSessionBackgroundProviderContext, ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; -import { ISessionData, SessionStatus } from '../../sessions/common/sessionData.js'; +import { ISession } from '../../sessions/common/sessionData.js'; import { Menus } from '../../../browser/menus.js'; import { INonSessionTaskEntry, ISessionsConfigurationService, ISessionTaskWithTarget, ITaskEntry, TaskStorageTarget } from './sessionsConfigurationService.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { IRunScriptCustomTaskWidgetResult, RunScriptCustomTaskWidget } from './runScriptCustomTaskWidget.js'; -import { NewChatViewPane, SessionsViewId } from './newChatViewPane.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; @@ -93,7 +90,7 @@ function getPrimaryTask(tasks: readonly ISessionTaskWithTarget[], pinnedTaskLabe } interface IRunScriptActionContext { - readonly session: ISessionData; + readonly session: ISession; readonly tasks: readonly ISessionTaskWithTarget[]; readonly pinnedTaskLabel: string | undefined; } @@ -111,12 +108,10 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr private readonly _activeRunState: IObservable; constructor( - @ISessionsManagementService private readonly _activeSessionService: ISessionsManagementService, + @ISessionsManagementService private readonly _sessionManagementService: ISessionsManagementService, @IKeybindingService _keybindingService: IKeybindingService, @IQuickInputService private readonly _quickInputService: IQuickInputService, @ISessionsConfigurationService private readonly _sessionsConfigService: ISessionsConfigurationService, - @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, - @IViewsService private readonly _viewsService: IViewsService, @IActionViewItemService private readonly _actionViewItemService: IActionViewItemService, ) { super(); @@ -135,7 +130,7 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr && t1.task.runOptions?.runOn === t2.task.runOptions?.runOn); } }, reader => { - const activeSession = this._activeSessionService.activeSession.read(reader); + const activeSession = this._sessionManagementService.activeSession.read(reader); if (!activeSession) { return undefined; } @@ -164,8 +159,8 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr action, options, that._activeRunState, - (session: ISessionData) => that._showConfigureQuickPick(session), - (session: ISessionData, existingTask: INonSessionTaskEntry, mode?: TaskConfigurationMode) => that._showCustomCommandInput(session, existingTask, mode), + (session: ISession) => that._showConfigureQuickPick(session), + (session: ISession, existingTask: INonSessionTaskEntry, mode?: TaskConfigurationMode) => that._showCustomCommandInput(session, existingTask, mode), ); }, )); @@ -258,20 +253,13 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr } async run(): Promise { - const status = session.status.read(undefined); - if (status === SessionStatus.Untitled) { - const viewPane = that._viewsService.getViewWithId(SessionsViewId); - viewPane?.sendQuery('/generate-run-commands'); - } else { - const widget = that._chatWidgetService.getWidgetBySessionResource(session.resource); - await widget?.acceptInput('/generate-run-commands'); - } + await that._sessionManagementService.sendAndCreateChat({ query: '/generate-run-commands' }, session); } })); })); } - private async _showConfigureQuickPick(session: ISessionData): Promise { + private async _showConfigureQuickPick(session: ISession): Promise { const nonSessionTasks = await this._sessionsConfigService.getNonSessionTasks(session); if (nonSessionTasks.length === 0) { // No existing tasks, go straight to custom command input @@ -320,7 +308,7 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr } } - private async _showCustomCommandInput(session: ISessionData, existingTask?: INonSessionTaskEntry, mode: TaskConfigurationMode = 'add'): Promise { + private async _showCustomCommandInput(session: ISession, existingTask?: INonSessionTaskEntry, mode: TaskConfigurationMode = 'add'): Promise { const taskConfiguration = await this._showCustomCommandWidget(session, existingTask, mode); if (!taskConfiguration) { return undefined; @@ -374,7 +362,7 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr ); } - private _showCustomCommandWidget(session: ISessionData, existingTask?: INonSessionTaskEntry, mode: TaskConfigurationMode = 'add'): Promise { + private _showCustomCommandWidget(session: ISession, existingTask?: INonSessionTaskEntry, mode: TaskConfigurationMode = 'add'): Promise { const repo = session.workspace.get()?.repositories[0]; const workspaceTargetDisabledReason = !(repo?.workingDirectory ?? repo?.uri) ? localize('workspaceStorageUnavailableTooltip', "Workspace storage is unavailable for this session") @@ -450,16 +438,15 @@ class RunScriptActionViewItem extends BaseActionViewItem { action: IAction, _options: IActionViewItemOptions, private readonly _activeRunState: IObservable, - private readonly _showConfigureQuickPick: (session: ISessionData) => Promise, - private readonly _showCustomCommandInput: (session: ISessionData, existingTask: INonSessionTaskEntry, mode?: TaskConfigurationMode) => Promise, + private readonly _showConfigureQuickPick: (session: ISession) => Promise, + private readonly _showCustomCommandInput: (session: ISession, existingTask: INonSessionTaskEntry, mode?: TaskConfigurationMode) => Promise, @ICommandService private readonly _commandService: ICommandService, @ISessionsConfigurationService private readonly _sessionsConfigService: ISessionsConfigurationService, @IKeybindingService private readonly _keybindingService: IKeybindingService, @IActionWidgetService private readonly _actionWidgetService: IActionWidgetService, @IContextKeyService contextKeyService: IContextKeyService, @ITelemetryService telemetryService: ITelemetryService, - @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, - @IViewsService private readonly _viewsService: IViewsService, + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, ) { super(undefined, action); @@ -677,13 +664,7 @@ class RunScriptActionViewItem extends BaseActionViewItem { class: undefined, category: addCategory, run: async () => { - if (session.status.get() === SessionStatus.Untitled) { - const viewPane = this._viewsService.getViewWithId(SessionsViewId); - viewPane?.sendQuery('/generate-run-commands'); - } else { - const widget = this._chatWidgetService.getWidgetBySessionResource(session.resource); - await widget?.acceptInput('/generate-run-commands'); - } + await this._sessionsManagementService.sendAndCreateChat({ query: '/generate-run-commands' }, session); }, }); diff --git a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts index c412615f0366b..1535d45146d6e 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts @@ -34,8 +34,7 @@ export class SessionTypePicker extends Disposable { this._register(autorun(reader => { const session = this.sessionsManagementService.activeSession.read(reader); if (session) { - const chat = session.activeChat.read(reader); - this._sessionTypes = this.sessionsProvidersService.getSessionTypes(chat); + this._sessionTypes = this.sessionsProvidersService.getSessionTypesForProvider(session.providerId); this._sessionType = session.sessionType; } else { this._sessionTypes = []; diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index c70e5fd750aae..c97ee90fcf2b2 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -64,6 +64,8 @@ export class WorkspacePicker extends Disposable { private readonly _onDidSelectWorkspace = this._register(new Emitter()); readonly onDidSelectWorkspace: Event = this._onDidSelectWorkspace.event; + private readonly _onDidChangeSelection = this._register(new Emitter()); + readonly onDidChangeSelection: Event = this._onDidChangeSelection.event; private _selectedWorkspace: IWorkspaceSelection | undefined; @@ -105,6 +107,7 @@ export class WorkspacePicker extends Disposable { if (restored) { this._selectedWorkspace = restored; this._updateTriggerLabel(); + this._onDidChangeSelection.fire(); this._onDidSelectWorkspace.fire(restored); } } @@ -124,6 +127,8 @@ export class WorkspacePicker extends Disposable { const trigger = dom.append(slot, dom.$('a.action-label')); trigger.tabIndex = 0; trigger.role = 'button'; + trigger.setAttribute('aria-haspopup', 'listbox'); + trigger.setAttribute('aria-expanded', 'false'); this._triggerElement = trigger; this._updateTriggerLabel(); @@ -164,10 +169,16 @@ export class WorkspacePicker extends Disposable { this._selectProject(item.selection); } }, - onHide: () => { triggerElement.focus(); }, + onHide: () => { + triggerElement.setAttribute('aria-expanded', 'false'); + triggerElement.focus(); + }, }; - const listOptions = showFilter ? { showFilter: true, filterPlaceholder: localize('workspacePicker.filter', "Search Workspaces..."), reserveSubmenuSpace: false } : { reserveSubmenuSpace: false }; + const listOptions = showFilter + ? { showFilter: true, filterPlaceholder: localize('workspacePicker.filter', "Search Workspaces..."), reserveSubmenuSpace: false } + : { reserveSubmenuSpace: false }; + triggerElement.setAttribute('aria-expanded', 'true'); this.actionWidgetService.show( 'workspacePicker', @@ -197,12 +208,14 @@ export class WorkspacePicker extends Disposable { * Clears the selected project. */ clearSelection(): void { + this.actionWidgetService.hide(); this._selectedWorkspace = undefined; // Clear checked state from all recents const recents = this._getStoredRecentWorkspaces(); const updated = recents.map(p => ({ ...p, checked: false })); this.storageService.store(STORAGE_KEY_RECENT_WORKSPACES, JSON.stringify(updated), StorageScope.PROFILE, StorageTarget.MACHINE); this._updateTriggerLabel(); + this._onDidChangeSelection.fire(); } /** @@ -218,6 +231,7 @@ export class WorkspacePicker extends Disposable { this._selectedWorkspace = selection; this._persistSelectedWorkspace(selection); this._updateTriggerLabel(); + this._onDidChangeSelection.fire(); if (fireEvent) { this._onDidSelectWorkspace.fire(selection); } @@ -367,13 +381,13 @@ export class WorkspacePicker extends Disposable { dom.clearNode(this._triggerElement); const workspace = this._selectedWorkspace?.workspace; - const label = workspace ? workspace.label : localize('pickWorkspace', "Pick a Workspace"); + const label = workspace ? workspace.label : localize('pickWorkspace', "workspace"); const icon = workspace ? workspace.icon : Codicon.project; dom.append(this._triggerElement, renderIcon(icon)); const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); labelSpan.textContent = label; - dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); + dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)).classList.add('sessions-chat-dropdown-chevron'); } private _isSelectedWorkspace(selection: IWorkspaceSelection): boolean { @@ -521,8 +535,10 @@ export class WorkspacePicker extends Disposable { // Clear current selection if it was the removed workspace if (this._isSelectedWorkspace(selection)) { + this.actionWidgetService.hide(); this._selectedWorkspace = undefined; this._updateTriggerLabel(); + this._onDidChangeSelection.fire(); } } diff --git a/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts b/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts index 39d52cfe857a5..599e524c9fb28 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts @@ -11,8 +11,7 @@ import { URI } from '../../../../base/common/uri.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; -import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; -import { ISessionData } from '../../sessions/common/sessionData.js'; +import { ISession } from '../../sessions/common/sessionData.js'; import { IJSONEditingService } from '../../../../workbench/services/configuration/common/jsonEditing.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IPreferencesService } from '../../../../workbench/services/preferences/common/preferences.js'; @@ -71,42 +70,42 @@ export interface ISessionsConfigurationService { * updated when the tasks.json file changes. Each entry includes the * storage target the task was loaded from. */ - getSessionTasks(session: ISessionData): IObservable; + getSessionTasks(session: ISession): IObservable; /** * Returns tasks that do NOT have `inSessions: true` — used as * suggestions in the "Add Run Action" picker. */ - getNonSessionTasks(session: ISessionData): Promise; + getNonSessionTasks(session: ISession): Promise; /** * Sets `inSessions: true` on an existing task (identified by label), * updating it in place in its tasks.json. */ - addTaskToSessions(task: ITaskEntry, session: ISessionData, target: TaskStorageTarget, options?: ITaskRunOptions): Promise; + addTaskToSessions(task: ITaskEntry, session: ISession, target: TaskStorageTarget, options?: ITaskRunOptions): Promise; /** * Creates a new shell task with `inSessions: true` and writes it to * the appropriate tasks.json (user or workspace). */ - createAndAddTask(label: string | undefined, command: string, session: ISessionData, target: TaskStorageTarget, options?: ITaskRunOptions): Promise; + createAndAddTask(label: string | undefined, command: string, session: ISession, target: TaskStorageTarget, options?: ITaskRunOptions): Promise; /** * Updates an existing task entry, optionally moving it between user and * workspace storage. */ - updateTask(originalTaskLabel: string, updatedTask: ITaskEntry, session: ISessionData, currentTarget: TaskStorageTarget, newTarget: TaskStorageTarget): Promise; + updateTask(originalTaskLabel: string, updatedTask: ITaskEntry, session: ISession, currentTarget: TaskStorageTarget, newTarget: TaskStorageTarget): Promise; /** * Removes an existing task entry from its tasks.json. */ - removeTask(taskLabel: string, session: ISessionData, target: TaskStorageTarget): Promise; + removeTask(taskLabel: string, session: ISession, target: TaskStorageTarget): Promise; /** * Runs a task via the task service, looking it up by label in the * workspace folder corresponding to the session worktree. */ - runTask(task: ITaskEntry, session: ISessionData): Promise; + runTask(task: ITaskEntry, session: ISession): Promise; /** * Observable label of the pinned task for the given repository. @@ -140,14 +139,13 @@ export class SessionsConfigurationService extends Disposable implements ISession @IPreferencesService private readonly _preferencesService: IPreferencesService, @ITaskService private readonly _taskService: ITaskService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, - @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @IStorageService private readonly _storageService: IStorageService, ) { super(); this._pinnedTaskLabels = this._loadPinnedTaskLabels(); } - getSessionTasks(session: ISessionData): IObservable { + getSessionTasks(session: ISession): IObservable { const repo = this._getSessionRepo(session); const folder = repo?.workingDirectory ?? repo?.uri; if (folder) { @@ -161,7 +159,7 @@ export class SessionsConfigurationService extends Disposable implements ISession return this._sessionTasks; } - async getNonSessionTasks(session: ISessionData): Promise { + async getNonSessionTasks(session: ISession): Promise { const result: INonSessionTaskEntry[] = []; const workspaceUri = this._getTasksJsonUri(session, 'workspace'); @@ -187,7 +185,7 @@ export class SessionsConfigurationService extends Disposable implements ISession return result; } - async addTaskToSessions(task: ITaskEntry, session: ISessionData, target: TaskStorageTarget, options?: ITaskRunOptions): Promise { + async addTaskToSessions(task: ITaskEntry, session: ISession, target: TaskStorageTarget, options?: ITaskRunOptions): Promise { const tasksJsonUri = this._getTasksJsonUri(session, target); if (!tasksJsonUri) { return; @@ -212,13 +210,9 @@ export class SessionsConfigurationService extends Disposable implements ISession } await this._jsonEditingService.write(tasksJsonUri, edits, true); - - if (target === 'workspace') { - await this._commitTasksFile(session); - } } - async createAndAddTask(label: string | undefined, command: string, session: ISessionData, target: TaskStorageTarget, options?: ITaskRunOptions): Promise { + async createAndAddTask(label: string | undefined, command: string, session: ISession, target: TaskStorageTarget, options?: ITaskRunOptions): Promise { const tasksJsonUri = this._getTasksJsonUri(session, target); if (!tasksJsonUri) { return undefined; @@ -240,14 +234,10 @@ export class SessionsConfigurationService extends Disposable implements ISession { path: ['tasks'], value: [...tasks, newTask] } ], true); - if (target === 'workspace') { - await this._commitTasksFile(session); - } - return newTask; } - async updateTask(originalTaskLabel: string, updatedTask: ITaskEntry, session: ISessionData, currentTarget: TaskStorageTarget, newTarget: TaskStorageTarget): Promise { + async updateTask(originalTaskLabel: string, updatedTask: ITaskEntry, session: ISession, currentTarget: TaskStorageTarget, newTarget: TaskStorageTarget): Promise { const currentTasksJsonUri = this._getTasksJsonUri(session, currentTarget); const newTasksJsonUri = this._getTasksJsonUri(session, newTarget); if (!currentTasksJsonUri || !newTasksJsonUri) { @@ -279,10 +269,6 @@ export class SessionsConfigurationService extends Disposable implements ISession ], true); } - if (currentTarget === 'workspace' || newTarget === 'workspace') { - await this._commitTasksFile(session); - } - const repoUri = this._getSessionRepo(session)?.uri; if (repoUri) { const key = repoUri.toString(); @@ -292,7 +278,7 @@ export class SessionsConfigurationService extends Disposable implements ISession } } - async removeTask(taskLabel: string, session: ISessionData, target: TaskStorageTarget): Promise { + async removeTask(taskLabel: string, session: ISession, target: TaskStorageTarget): Promise { const tasksJsonUri = this._getTasksJsonUri(session, target); if (!tasksJsonUri) { return; @@ -309,10 +295,6 @@ export class SessionsConfigurationService extends Disposable implements ISession { path: ['tasks'], value: tasks.filter((_, taskIndex) => taskIndex !== index) }, ], true); - if (target === 'workspace') { - await this._commitTasksFile(session); - } - const repoUri = this._getSessionRepo(session)?.uri; if (repoUri) { const key = repoUri.toString(); @@ -322,7 +304,7 @@ export class SessionsConfigurationService extends Disposable implements ISession } } - async runTask(task: ITaskEntry, session: ISessionData): Promise { + async runTask(task: ITaskEntry, session: ISession): Promise { const repo = this._getSessionRepo(session); const cwd = repo?.workingDirectory ?? repo?.uri; if (!cwd) { @@ -366,11 +348,11 @@ export class SessionsConfigurationService extends Disposable implements ISession // --- private helpers --- - private _getSessionRepo(session: ISessionData) { + private _getSessionRepo(session: ISession) { return session.workspace.get()?.repositories[0]; } - private _getTasksJsonUri(session: ISessionData, target: TaskStorageTarget): URI | undefined { + private _getTasksJsonUri(session: ISession, target: TaskStorageTarget): URI | undefined { if (target === 'workspace') { const repo = this._getSessionRepo(session); const folder = repo?.workingDirectory ?? repo?.uri; @@ -439,15 +421,6 @@ export class SessionsConfigurationService extends Disposable implements ISession transaction(tx => this._sessionTasks.set([...sessionTasks, ...userSessionTasks], tx)); } - private async _commitTasksFile(session: ISessionData): Promise { - const worktree = this._getSessionRepo(session)?.workingDirectory; // Only commit if there's a worktree. The local scenario does not need it - if (!worktree) { - return; - } - const tasksUri = joinPath(worktree, '.vscode', 'tasks.json'); - await this._sessionsManagementService.commitWorktreeFiles(session, [tasksUri]); - } - private _loadPinnedTaskLabels(): Map { const raw = this._storageService.get(SessionsConfigurationService._PINNED_TASK_LABELS_KEY, StorageScope.APPLICATION); if (raw) { diff --git a/src/vs/sessions/contrib/chat/browser/workspacePicker.ts b/src/vs/sessions/contrib/chat/browser/workspacePicker.ts deleted file mode 100644 index a4a092d83492f..0000000000000 --- a/src/vs/sessions/contrib/chat/browser/workspacePicker.ts +++ /dev/null @@ -1,4 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts index 27f73414a9995..3710898b735e4 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts @@ -20,10 +20,10 @@ import { VSBuffer } from '../../../../../base/common/buffer.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { Task } from '../../../../../workbench/contrib/tasks/common/tasks.js'; import { ITaskService } from '../../../../../workbench/contrib/tasks/common/taskService.js'; -import { IChatData, ISessionData, SessionStatus } from '../../../sessions/common/sessionData.js'; +import { IChat, ISession, SessionStatus } from '../../../sessions/common/sessionData.js'; import { Codicon } from '../../../../../base/common/codicons.js'; -function makeSession(opts: { repository?: URI; worktree?: URI } = {}): ISessionData { +function makeSession(opts: { repository?: URI; worktree?: URI } = {}): ISession { const workspace = opts.repository ? { label: 'test', icon: Codicon.folder, @@ -36,7 +36,7 @@ function makeSession(opts: { repository?: URI; worktree?: URI } = {}): ISessionD }], requiresWorkspaceTrust: false, } : undefined; - const chat: IChatData = { + const chat: IChat = { chatId: 'test:session', resource: URI.parse('file:///session'), providerId: 'test', @@ -57,7 +57,7 @@ function makeSession(opts: { repository?: URI; worktree?: URI } = {}): ISessionD description: observableValue('description', undefined), pullRequest: observableValue('pullRequest', undefined), }; - const session: ISessionData = { ...chat, sessionId: chat.chatId, chats: observableValue('chats', [chat]), activeChat: observableValue('activeChat', chat), mainChat: chat }; + const session: ISession = { ...chat, sessionId: chat.chatId, chats: observableValue('chats', [chat]), activeChat: observableValue('activeChat', chat), mainChat: chat }; return session; } @@ -84,10 +84,9 @@ suite('SessionsConfigurationService', () => { let fileContents: Map; let jsonEdits: { uri: URI; values: IJSONValue[] }[]; let ranTasks: { label: string }[]; - let committedFiles: { session: ISessionData; fileUris: URI[] }[]; let storageService: InMemoryStorageService; let readFileCalls: URI[]; - let activeSessionObs: ReturnType>; + let activeSessionObs: ReturnType>; let tasksByLabel: Map; let workspaceFoldersByUri: Map; @@ -99,7 +98,6 @@ suite('SessionsConfigurationService', () => { fileContents = new Map(); jsonEdits = []; ranTasks = []; - committedFiles = []; readFileCalls = []; tasksByLabel = new Map(); workspaceFoldersByUri = new Map(); @@ -151,7 +149,6 @@ suite('SessionsConfigurationService', () => { instantiationService.stub(ISessionsManagementService, new class extends mock() { override activeSession = activeSessionObs; - override async commitWorktreeFiles(session: ISessionData, fileUris: URI[]) { committedFiles.push({ session, fileUris }); } }); storageService = store.add(new InMemoryStorageService()); @@ -306,8 +303,6 @@ suite('SessionsConfigurationService', () => { assert.strictEqual(jsonEdits.length, 1); assert.deepStrictEqual(jsonEdits[0].values, [{ path: ['tasks', 1, 'inSessions'], value: true }]); - assert.strictEqual(committedFiles.length, 1); - assert.strictEqual(committedFiles[0].fileUris[0].path, '/worktree/.vscode/tasks.json'); }); test('addTaskToSessions does nothing when task label not found', async () => { @@ -335,7 +330,6 @@ suite('SessionsConfigurationService', () => { assert.strictEqual(jsonEdits.length, 1); assert.strictEqual(jsonEdits[0].uri.toString(), repoTasksUri.toString()); assert.deepStrictEqual(jsonEdits[0].values, [{ path: ['tasks', 1, 'inSessions'], value: true }]); - assert.strictEqual(committedFiles.length, 0, 'should not commit when there is no worktree'); }); test('addTaskToSessions updates runOptions when provided', async () => { @@ -388,8 +382,6 @@ suite('SessionsConfigurationService', () => { assert.strictEqual(tasks.length, 2); assert.strictEqual(tasks[1].label, 'npm run dev'); assert.strictEqual(tasks[1].inSessions, true); - assert.strictEqual(committedFiles.length, 1); - assert.strictEqual(committedFiles[0].fileUris[0].path, '/worktree/.vscode/tasks.json'); }); test('createAndAddTask writes to repository and does not commit when no worktree', async () => { @@ -409,7 +401,6 @@ suite('SessionsConfigurationService', () => { assert.strictEqual(tasks.length, 2); assert.strictEqual(tasks[1].label, 'npm run dev'); assert.strictEqual(tasks[1].inSessions, true); - assert.strictEqual(committedFiles.length, 0, 'should not commit when there is no worktree'); }); test('createAndAddTask writes worktreeCreated run option when requested', async () => { @@ -462,8 +453,6 @@ suite('SessionsConfigurationService', () => { { label: 'lint', type: 'shell', command: 'npm run lint' }, ], }]); - assert.strictEqual(committedFiles.length, 1); - assert.strictEqual(committedFiles[0].fileUris[0].path, '/worktree/.vscode/tasks.json'); }); // --- updateTask --- @@ -495,7 +484,6 @@ suite('SessionsConfigurationService', () => { runOptions: { runOn: 'worktreeCreated' } } }]); - assert.strictEqual(committedFiles.length, 1); }); test('updateTask moves a task between workspace and user storage', async () => { @@ -542,7 +530,6 @@ suite('SessionsConfigurationService', () => { } ] }); - assert.strictEqual(committedFiles.length, 1); }); // --- pinned task --- diff --git a/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts index b1af990d5dda1..b0a16fd61617e 100644 --- a/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts +++ b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts @@ -41,7 +41,9 @@ function registerSessionCodeReviewAction(tooltip: string, icon: ThemeIcon): Disp tooltip, category: CHAT_CATEGORY, icon, - precondition: canRunSessionCodeReviewContextKey, + precondition: ContextKeyExpr.and( + ChatContextKeys.hasAgentSessionChanges, + canRunSessionCodeReviewContextKey), menu: [ { id: MenuId.ChatEditingSessionChangesToolbar, @@ -49,7 +51,6 @@ function registerSessionCodeReviewAction(tooltip: string, icon: ThemeIcon): Disp order: 7, when: ContextKeyExpr.and( IsSessionsWindowContext, - ChatContextKeys.hasAgentSessionChanges, ChatContextKeys.agentSessionType.notEqualsTo(AgentSessionProviders.Cloud), ), }, @@ -69,7 +70,7 @@ function registerSessionCodeReviewAction(tooltip: string, icon: ThemeIcon): Disp return; } - // Get changes from ISessionData + // Get changes from ISession const sessionData = sessionManagementService.getSession(resource); const changes = sessionData?.changes.get(); if (!changes || changes.length === 0) { diff --git a/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts b/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts index 2656f9e6164a3..8880210583841 100644 --- a/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts +++ b/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts @@ -18,7 +18,7 @@ import { InMemoryStorageService, IStorageService, StorageScope, StorageTarget } import { IChatSessionFileChange, IChatSessionFileChange2 } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; import { ISessionsChangeEvent, ISessionsManagementService } from '../../../sessions/browser/sessionsManagementService.js'; -import { ISessionData } from '../../../sessions/common/sessionData.js'; +import { ISession } from '../../../sessions/common/sessionData.js'; import { ICodeReviewService, CodeReviewService, CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion } from '../../browser/codeReviewService.js'; suite('CodeReviewService', () => { @@ -101,32 +101,32 @@ suite('CodeReviewService', () => { class MockSessionsManagementService extends mock() { private readonly _onDidChangeSessions: Emitter; override readonly onDidChangeSessions: Event; - override readonly activeSession: IObservable; + override readonly activeSession: IObservable; - private readonly _sessions = new Map(); + private readonly _sessions = new Map(); constructor(disposables: DisposableStore) { super(); this._onDidChangeSessions = disposables.add(new Emitter()); this.onDidChangeSessions = this._onDidChangeSessions.event; - this.activeSession = observableValue('test.activeSession', undefined); + this.activeSession = observableValue('test.activeSession', undefined); } - override getSession(resource: URI): ISessionData | undefined { + override getSession(resource: URI): ISession | undefined { return this._sessions.get(resource.toString()); } - addSession(resource: URI, changes?: readonly IChatSessionFileChange2[], archived = false): ISessionData { + addSession(resource: URI, changes?: readonly IChatSessionFileChange2[], archived = false): ISession { const changesObs = observableValue('test.changes', (changes ?? []).map(c => ({ modifiedUri: c.modifiedUri ?? c.uri, originalUri: c.originalUri, insertions: c.insertions, deletions: c.deletions })) ); const isArchivedObs = observableValue('test.isArchived', archived); - const sessionData: ISessionData = { + const sessionData: ISession = { sessionId: `test:${resource.toString()}`, resource, changes: changesObs, isArchived: isArchivedObs, - } as unknown as ISessionData; + } as unknown as ISession; this._sessions.set(resource.toString(), sessionData); return sessionData; } @@ -146,7 +146,7 @@ suite('CodeReviewService', () => { this._sessions.delete(resource.toString()); } - override getSessions(): ISessionData[] { + override getSessions(): ISession[] { return [...this._sessions.values()]; } diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts index f7300a7ab3230..38dfa6b308c36 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts @@ -12,6 +12,7 @@ import { IActionWidgetService } from '../../../../platform/actionWidget/browser/ import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.js'; import { CopilotCLISession } from './copilotChatSessionsProvider.js'; const FILTER_THRESHOLD = 10; @@ -34,26 +35,31 @@ export class BranchPicker extends Disposable { constructor( @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, ) { super(); this._register(autorun(reader => { const session = this.sessionsManagementService.activeSession.read(reader); - const chat = session?.activeChat.read(reader); - if (chat instanceof CopilotCLISession) { - chat.loading.read(reader); - chat.branches.read(reader); - chat.branchesLoading.read(reader); - chat.branchObservable.read(reader); - chat.isolationModeObservable.read(reader); + const providerSession = session ? this.sessionsProvidersService.getUntitledSession(session.providerId) : undefined; + if (providerSession instanceof CopilotCLISession) { + providerSession.loading.read(reader); + providerSession.branches.read(reader); + providerSession.branchesLoading.read(reader); + providerSession.branchObservable.read(reader); + providerSession.isolationModeObservable.read(reader); } this._updateTriggerLabel(); })); } private _getSession(): CopilotCLISession | undefined { - const chat = this.sessionsManagementService.activeSession.get()?.activeChat.get(); - return chat instanceof CopilotCLISession ? chat : undefined; + const session = this.sessionsManagementService.activeSession.get(); + if (!session) { + return undefined; + } + const providerSession = this.sessionsProvidersService.getUntitledSession(session.providerId); + return providerSession instanceof CopilotCLISession ? providerSession : undefined; } render(container: HTMLElement): void { diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts index 4a5b185ff0d78..5bbb4042257cc 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts @@ -25,7 +25,7 @@ import { Menus } from '../../../browser/menus.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.js'; import { SessionItemContextMenuId } from '../../sessions/browser/views/sessionsList.js'; -import { ISessionData } from '../../sessions/common/sessionData.js'; +import { ISession } from '../../sessions/common/sessionData.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { CopilotCLISession, COPILOT_PROVIDER_ID } from './copilotChatSessionsProvider.js'; import { COPILOT_CLI_SESSION_TYPE, COPILOT_CLOUD_SESSION_TYPE } from '../../sessions/browser/sessionTypes.js'; @@ -289,6 +289,7 @@ class CopilotActiveSessionContribution extends Disposable implements IWorkbenchC constructor( @ISessionsManagementService sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, @IContextKeyService contextKeyService: IContextKeyService, ) { super(); @@ -297,10 +298,10 @@ class CopilotActiveSessionContribution extends Disposable implements IWorkbenchC this._register(autorun((reader: IReader) => { const session = sessionsManagementService.activeSession.read(reader); - const chat = session?.activeChat.read(reader); - if (chat instanceof CopilotCLISession) { - const isLoading = chat.loading.read(reader); - hasRepositoryKey.set(!isLoading && !!chat.gitRepository); + const providerSession = session ? sessionsProvidersService.getUntitledSession(session.providerId) : undefined; + if (providerSession instanceof CopilotCLISession) { + const isLoading = providerSession.loading.read(reader); + hasRepositoryKey.set(!isLoading && !!providerSession.gitRepository); } else { hasRepositoryKey.set(false); } @@ -314,7 +315,7 @@ registerWorkbenchContribution2(CopilotActiveSessionContribution.ID, CopilotActiv /** * Bridges extension-contributed context menu actions from {@link MenuId.AgentSessionsContext} * to {@link SessionItemContextMenuId} for the new sessions view. - * Registers wrapper commands that resolve {@link ISessionData} → {@link IAgentSession} + * Registers wrapper commands that resolve {@link ISession} → {@link IAgentSession} * and forward to the original command with marshalled context. */ class CopilotSessionContextMenuBridge extends Disposable implements IWorkbenchContribution { @@ -348,7 +349,7 @@ class CopilotSessionContextMenuBridge extends Disposable implements IWorkbenchCo this._bridgedIds.add(commandId); const wrapperId = `sessionsViewPane.bridge.${commandId}`; - this._register(CommandsRegistry.registerCommand(wrapperId, (accessor, sessionData?: ISessionData) => { + this._register(CommandsRegistry.registerCommand(wrapperId, (accessor, sessionData?: ISession) => { if (!sessionData) { return; } diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 34fa0538a0fe0..5e589000c5484 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -18,10 +18,10 @@ import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browse import { AgentSessionProviders, AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { IChatService, IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { ChatSessionStatus, IChatSessionFileChange, IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { IChatData, ISessionRepository, ISessionWorkspace, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, ISessionPullRequest } from '../../sessions/common/sessionData.js'; +import { ISessionData, ISessionRepository, ISessionWorkspace, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, ISessionPullRequest } from '../../sessions/common/sessionData.js'; import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; import { basename } from '../../../../base/common/resources.js'; -import { ISendRequestOptions, ISessionsBrowseAction, IChatChangeEvent, ISessionsProvider, ISessionType } from '../../sessions/browser/sessionsProvider.js'; +import { ISendRequestOptions, ISessionsBrowseAction, ISessionChangeEvent, ISessionsProvider, ISessionType } from '../../sessions/browser/sessionsProvider.js'; import { ISessionOptionGroup } from '../../chat/browser/newSession.js'; import { IsolationMode } from './isolationPicker.js'; import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; @@ -52,7 +52,7 @@ const AGENT_OPTION_ID = 'agent'; * Provider-specific observable fields on new Copilot sessions. * Used by pickers and contributions that need to read/write provider-internal state. */ -export interface ICopilotNewSessionData extends IChatData { +export interface ICopilotNewSessionData extends ISessionData { readonly permissionLevel: IObservable; readonly branchObservable: IObservable; readonly isolationModeObservable: IObservable; @@ -60,16 +60,16 @@ export interface ICopilotNewSessionData extends IChatData { /** * Local new session for Background agent sessions. - * Implements {@link IChatData} (session facade) and provides + * Implements {@link ISessionData} (session facade) and provides * pre-send configuration methods for the new-session flow. */ -export class CopilotCLISession extends Disposable implements IChatData { +export class CopilotCLISession extends Disposable implements ISessionData { static readonly COPILOT_WORKTREE_PATTERN = 'copilot-worktree-'; - // -- IChatData fields -- + // -- ISessionData fields -- - readonly chatId: string; + readonly id: string; readonly providerId: string; readonly sessionType: string; readonly icon: ThemeIcon; @@ -164,7 +164,7 @@ export class CopilotCLISession extends Disposable implements IChatData { @IGitService private readonly gitService: IGitService, ) { super(); - this.chatId = `${providerId}:${resource.toString()}`; + this.id = `${providerId}:${resource.toString()}`; this.providerId = providerId; this.sessionType = AgentSessionProviders.Background; this.icon = CopilotCLISessionType.icon; @@ -176,7 +176,7 @@ export class CopilotCLISession extends Disposable implements IChatData { this.setOption(REPOSITORY_OPTION_ID, repoUri.fsPath); } - // Set IChatData workspace observable + // Set ISessionData workspace observable this._workspaceData.set(sessionWorkspace, undefined); this._isolationMode = 'worktree'; @@ -297,7 +297,7 @@ export class CopilotCLISession extends Disposable implements IChatData { this.chatSessionsService.setSessionOption(this.resource, optionId, value); } - update(session: IChatData): void { + update(session: ISessionData): void { this._workspaceData.set(session.workspace.get(), undefined); } } @@ -316,14 +316,14 @@ function isRepositoriesOptionGroup(group: IChatSessionProviderOptionGroup): bool /** * Remote new session for Cloud agent sessions. - * Implements {@link IChatData} (session facade) and provides + * Implements {@link ISessionData} (session facade) and provides * pre-send configuration methods for the new-session flow. */ -export class RemoteNewSession extends Disposable implements IChatData { +export class RemoteNewSession extends Disposable implements ISessionData { - // -- IChatData fields -- + // -- ISessionData fields -- - readonly chatId: string; + readonly id: string; readonly providerId: string; readonly sessionType: string; readonly icon: ThemeIcon; @@ -397,7 +397,7 @@ export class RemoteNewSession extends Disposable implements IChatData { @IContextKeyService private readonly contextKeyService: IContextKeyService, ) { super(); - this.chatId = `${providerId}:${resource.toString()}`; + this.id = `${providerId}:${resource.toString()}`; this.providerId = providerId; this.sessionType = target; this.icon = CopilotCloudSessionType.icon; @@ -533,7 +533,7 @@ export class RemoteNewSession extends Disposable implements IChatData { return group.items.find(i => i.default === true) ?? group.items[0]; } - update(session: IChatData): void { } + update(session: ISessionData): void { } } /** @@ -553,11 +553,11 @@ function toSessionStatus(status: ChatSessionStatus): SessionStatus { } /** - * Adapts an existing {@link IAgentSession} from the chat layer into the new {@link IChatData} facade. + * Adapts an existing {@link IAgentSession} from the chat layer into the new {@link ISessionData} facade. */ -class AgentSessionAdapter implements IChatData { +class AgentSessionAdapter implements ISessionData { - readonly chatId: string; + readonly id: string; readonly resource: URI; readonly providerId: string; readonly sessionType: string; @@ -602,7 +602,7 @@ class AgentSessionAdapter implements IChatData { session: IAgentSession, providerId: string, ) { - this.chatId = `${providerId}:${session.resource.toString()}`; + this.id = `${providerId}:${session.resource.toString()}`; this.resource = session.resource; this.providerId = providerId; this.sessionType = session.providerType; @@ -816,8 +816,8 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions readonly icon = Codicon.copilot; readonly sessionTypes: readonly ISessionType[] = [CopilotCLISessionType, CopilotCloudSessionType]; - private readonly _onDidChangeSessions = this._register(new Emitter()); - readonly onDidChangeSessions: Event = this._onDidChangeSessions.event; + private readonly _onDidChangeSessions = this._register(new Emitter()); + readonly onDidChangeSessions: Event = this._onDidChangeSessions.event; /** Cache of adapted sessions, keyed by resource URI string. */ private readonly _sessionCache = new Map(); @@ -862,7 +862,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions // -- Sessions -- - getSessionTypes(session: IChatData): ISessionType[] { + getSessionTypes(session: ISessionData): ISessionType[] { if (session instanceof CopilotCLISession) { return [CopilotCLISessionType]; } @@ -872,7 +872,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return []; } - getSessions(): IChatData[] { + getSessions(): ISessionData[] { this._ensureSessionCache(); return Array.from(this._sessionCache.values()); } @@ -881,7 +881,11 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions private _currentNewSession: (CopilotCLISession | RemoteNewSession) | undefined; - createNewSession(workspace: ISessionWorkspace): IChatData { + getUntitledSession(): ISessionData | undefined { + return this._currentNewSession; + } + + createNewSession(workspace: ISessionWorkspace): ISessionData { const workspaceUri = workspace.repositories[0]?.uri; if (!workspaceUri) { throw new Error('Workspace has no repository URI'); @@ -905,7 +909,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return session; } - createNewSessionFrom(chatId: string): IChatData { + createNewSessionFrom(chatId: string): ISessionData { const chat = this._findChatSession(chatId); if (!chat) { throw new Error(`Session '${chatId}' not found`); @@ -938,12 +942,12 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return session; } - setSessionType(chatId: string, type: ISessionType): IChatData { + setSessionType(chatId: string, type: ISessionType): ISessionData { throw new Error('Session type cannot be changed'); } setModel(chatId: string, modelId: string): void { - if (this._currentNewSession?.chatId === chatId) { + if (this._currentNewSession?.id === chatId) { this._currentNewSession.setModelId(modelId); } } @@ -996,9 +1000,9 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions // -- Send -- - async sendRequest(chatId: string, options: ISendRequestOptions): Promise { + async sendRequest(chatId: string, options: ISendRequestOptions): Promise { const session = this._currentNewSession; - if (!session || session.chatId !== chatId) { + if (!session || session.id !== chatId) { throw new Error(`Session '${chatId}' not found or not a new session`); } @@ -1084,12 +1088,12 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return createdSession; } - private async _waitForNewAgentSession(target: AgentSessionTarget, existingSessions: ResourceSet): Promise { + private async _waitForNewAgentSession(target: AgentSessionTarget, existingSessions: ResourceSet): Promise { const found = [...this._sessionCache.values()].find(s => s.sessionType === target && !existingSessions.has(s.resource)); if (found) { return found; } - return new Promise(resolve => { + return new Promise(resolve => { const listener = this.onDidChangeSessions((e) => { const s = e.added.find(s => s.sessionType === target && !existingSessions.has(s.resource)); if (s) { @@ -1166,8 +1170,8 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions private _refreshSessionCache(): void { const currentKeys = new Set(); - const added: IChatData[] = []; - const changed: IChatData[] = []; + const added: ISessionData[] = []; + const changed: ISessionData[] = []; for (const session of this.agentSessionsService.model.sessions) { if (session.resource.toString() === this._currentNewSession?.resource.toString()) { @@ -1194,7 +1198,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } } - const removed: IChatData[] = []; + const removed: ISessionData[] = []; for (const [key, adapter] of this._sessionCache) { if (!currentKeys.has(key)) { this._sessionCache.delete(key); @@ -1207,7 +1211,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } } - private _findChatSession(chatId: string): IChatData | undefined { + private _findChatSession(chatId: string): ISessionData | undefined { return this._sessionCache.get(this._localIdFromchatId(chatId)); } diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/isolationPicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/isolationPicker.ts index cbf923c7cdaf1..aa0f455c61d26 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/isolationPicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/isolationPicker.ts @@ -13,6 +13,7 @@ import { IActionWidgetService } from '../../../../platform/actionWidget/browser/ import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.js'; import { CopilotCLISession } from './copilotChatSessionsProvider.js'; export type IsolationMode = 'worktree' | 'workspace'; @@ -42,6 +43,7 @@ export class IsolationPicker extends Disposable { @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, @IConfigurationService private readonly configurationService: IConfigurationService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, ) { super(); this._isolationOptionEnabled = this.configurationService.getValue('github.copilot.chat.cli.isolationOption.enabled') !== false; @@ -58,12 +60,12 @@ export class IsolationPicker extends Disposable { this._register(autorun(reader => { const session = this.sessionsManagementService.activeSession.read(reader); - const chat = session?.activeChat.read(reader); - if (chat instanceof CopilotCLISession) { - const isLoading = chat.loading.read(reader); - this._hasGitRepo = !isLoading && !!chat.gitRepository; + const providerSession = session ? this.sessionsProvidersService.getUntitledSession(session.providerId) : undefined; + if (providerSession instanceof CopilotCLISession) { + const isLoading = providerSession.loading.read(reader); + this._hasGitRepo = !isLoading && !!providerSession.gitRepository; // Read isolation mode from session — session is the source of truth - chat.isolationModeObservable.read(reader); + providerSession.isolationModeObservable.read(reader); } else { this._hasGitRepo = false; } @@ -72,8 +74,9 @@ export class IsolationPicker extends Disposable { } private _getSessionIsolationMode(): IsolationMode { - const session = this.sessionsManagementService.activeSession.get()?.activeChat.get(); - return session instanceof CopilotCLISession ? session.isolationMode : 'worktree'; + const session = this.sessionsManagementService.activeSession.get(); + const providerSession = session ? this.sessionsProvidersService.getUntitledSession(session.providerId) : undefined; + return providerSession instanceof CopilotCLISession ? providerSession.isolationMode : 'worktree'; } render(container: HTMLElement): void { @@ -151,11 +154,12 @@ export class IsolationPicker extends Disposable { } private _setModeOnSession(mode: IsolationMode): void { - const session = this.sessionsManagementService.activeSession.get()?.activeChat.get(); - if (!(session instanceof CopilotCLISession)) { + const session = this.sessionsManagementService.activeSession.get(); + const providerSession = session ? this.sessionsProvidersService.getUntitledSession(session.providerId) : undefined; + if (!(providerSession instanceof CopilotCLISession)) { throw new Error('IsolationPicker requires a CopilotCLISession'); } - session.setIsolationMode(mode); + providerSession.setIsolationMode(mode); } private _updateTriggerLabel(): void { diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts index 597888f97d998..b1292ffc9b17c 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts @@ -18,6 +18,7 @@ import { ICommandService } from '../../../../platform/commands/common/commands.j import { Target } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; import { AICustomizationManagementCommands } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.js'; import { CopilotCLISession } from './copilotChatSessionsProvider.js'; interface IModePickerItem { @@ -57,6 +58,7 @@ export class ModePicker extends Disposable { @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @ICommandService private readonly commandService: ICommandService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, ) { super(); @@ -212,9 +214,10 @@ export class ModePicker extends Disposable { this._updateTriggerLabel(); this._onDidChange.fire(mode); - const chat = this.sessionsManagementService.activeSession.get()?.activeChat.get(); - if (chat instanceof CopilotCLISession) { - chat.setMode(mode); + const session = this.sessionsManagementService.activeSession.get(); + const providerSession = session ? this.sessionsProvidersService.getUntitledSession(session.providerId) : undefined; + if (providerSession instanceof CopilotCLISession) { + providerSession.setMode(mode); } } diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/modelPicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/modelPicker.ts index 978a2dae27ddf..c8dbf57dfc64d 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/modelPicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/modelPicker.ts @@ -14,6 +14,7 @@ import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../ import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.js'; import { RemoteNewSession } from './copilotChatSessionsProvider.js'; const FILTER_THRESHOLD = 10; @@ -50,15 +51,16 @@ export class CloudModelPicker extends Disposable { constructor( @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, @ISessionsManagementService sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, @IChatSessionsService chatSessionsService: IChatSessionsService, ) { super(); this._register(autorun(reader => { const session = sessionsManagementService.activeSession.read(reader); - const chat = session?.activeChat.read(reader); - if (chat instanceof RemoteNewSession) { - this._setSession(chat); + const providerSession = session ? sessionsProvidersService.getUntitledSession(session.providerId) : undefined; + if (providerSession instanceof RemoteNewSession) { + this._setSession(providerSession); } })); diff --git a/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts index 07c84810bd456..13663448e191e 100644 --- a/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts +++ b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts @@ -43,7 +43,7 @@ import { IStorageService } from '../../../../platform/storage/common/storage.js' import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; -import { ISessionData, GITHUB_REMOTE_FILE_SCHEME } from '../../sessions/common/sessionData.js'; +import { ISession, GITHUB_REMOTE_FILE_SCHEME } from '../../sessions/common/sessionData.js'; import { getGitHubRemoteFileDisplayName } from './githubFileSystemProvider.js'; import { basename } from '../../../../base/common/path.js'; import { isEqual } from '../../../../base/common/resources.js'; @@ -236,10 +236,10 @@ export class FileTreeViewPane extends ViewPane { /** * Determines the root URI for the file tree based on the active session type. - * Tries multiple data sources: ISessionData workspace, agent session model metadata, + * Tries multiple data sources: ISession workspace, agent session model metadata, * and file change URIs as a last resort. */ - private resolveTreeRoot(activeSession: ISessionData | undefined): URI | undefined { + private resolveTreeRoot(activeSession: ISession | undefined): URI | undefined { if (!activeSession) { return undefined; } diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 0525bb3ae343f..4f7411d06fd2b 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -23,9 +23,9 @@ import { IChatSendRequestOptions, IChatService } from '../../../../workbench/con import { IChatSessionFileChange, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; -import { IChatChangeEvent, ISendRequestOptions, ISessionsBrowseAction, ISessionsProvider, ISessionType } from '../../sessions/browser/sessionsProvider.js'; +import { ISessionChangeEvent, ISendRequestOptions, ISessionsBrowseAction, ISessionsProvider, ISessionType } from '../../sessions/browser/sessionsProvider.js'; import { CopilotCLISessionType } from '../../sessions/browser/sessionTypes.js'; -import { IChatData, ISessionPullRequest, ISessionWorkspace, SessionStatus } from '../../sessions/common/sessionData.js'; +import { ISessionData, ISessionPullRequest, ISessionWorkspace, SessionStatus } from '../../sessions/common/sessionData.js'; import { IRemoteAgentHostConnectionInfo } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; export interface IRemoteAgentHostSessionsProviderConfig { @@ -34,11 +34,11 @@ export interface IRemoteAgentHostSessionsProviderConfig { } /** - * Adapts agent host session metadata into the {@link IChatData} facade. + * Adapts agent host session metadata into the {@link ISessionData} facade. */ -class RemoteSessionAdapter implements IChatData { +class RemoteSessionAdapter implements ISessionData { - readonly chatId: string; + readonly id: string; readonly resource: URI; readonly providerId: string; readonly sessionType: string; @@ -72,7 +72,7 @@ class RemoteSessionAdapter implements IChatData { const rawId = AgentSession.id(metadata.session); this.agentProvider = AgentSession.provider(metadata.session) ?? 'copilot'; this.resource = URI.from({ scheme: resourceScheme, path: `/${rawId}` }); - this.chatId = `${providerId}:${this.resource.toString()}`; + this.id = `${providerId}:${this.resource.toString()}`; this.providerId = providerId; this.sessionType = logicalSessionType; this.createdAt = new Date(metadata.startTime); @@ -117,8 +117,8 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess readonly icon: ThemeIcon = Codicon.remote; readonly sessionTypes: readonly ISessionType[]; - private readonly _onDidChangeSessions = this._register(new Emitter()); - readonly onDidChangeSessions: Event = this._onDidChangeSessions.event; + private readonly _onDidChangeSessions = this._register(new Emitter()); + readonly onDidChangeSessions: Event = this._onDidChangeSessions.event; readonly browseActions: readonly ISessionsBrowseAction[]; @@ -211,20 +211,24 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess // -- Sessions -- - getSessionTypes(_chat: IChatData): ISessionType[] { + getSessionTypes(_chat: ISessionData): ISessionType[] { return [...this.sessionTypes]; } - getSessions(): IChatData[] { + getSessions(): ISessionData[] { this._ensureSessionCache(); return Array.from(this._sessionCache.values()); } // -- Session Lifecycle -- - private _currentNewSession: IChatData | undefined; + private _currentNewSession: ISessionData | undefined; + + getUntitledSession(): ISessionData | undefined { + return this._currentNewSession; + } - createNewSession(workspace: ISessionWorkspace): IChatData { + createNewSession(workspace: ISessionWorkspace): ISessionData { const workspaceUri = workspace.repositories[0]?.uri; if (!workspaceUri) { throw new Error('Workspace has no repository URI'); @@ -235,8 +239,8 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess this._selectedModelId = undefined; const resource = URI.from({ scheme: this._sessionTypeForProvider('copilot'), path: `/untitled-${generateUuid()}` }); - const session: IChatData = { - chatId: `${this.id}:${resource.toString()}`, + const session: ISessionData = { + id: `${this.id}:${resource.toString()}`, resource, providerId: this.id, sessionType: this.sessionTypes[0].id, @@ -260,16 +264,16 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess return session; } - createNewSessionFrom(_chatId: string): IChatData { + createNewSessionFrom(_chatId: string): ISessionData { throw new Error('Remote agent host sessions do not support forking'); } - setSessionType(_chatId: string, _type: ISessionType): IChatData { + setSessionType(_chatId: string, _type: ISessionType): ISessionData { throw new Error('Remote agent host sessions do not support changing session type'); } setModel(chatId: string, modelId: string): void { - if (this._currentNewSession?.chatId === chatId) { + if (this._currentNewSession?.id === chatId) { this._selectedModelId = modelId; } } @@ -306,9 +310,9 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess } } - async sendRequest(chatId: string, options: ISendRequestOptions): Promise { + async sendRequest(chatId: string, options: ISendRequestOptions): Promise { const session = this._currentNewSession; - if (!session || session.chatId !== chatId) { + if (!session || session.id !== chatId) { throw new Error(`Session '${chatId}' not found or not a new session`); } @@ -386,8 +390,8 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess try { const sessions = await this._connection.listSessions(); const currentKeys = new Set(); - const added: IChatData[] = []; - const changed: IChatData[] = []; + const added: ISessionData[] = []; + const changed: ISessionData[] = []; for (const meta of sessions) { const rawId = AgentSession.id(meta.session); @@ -405,7 +409,7 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess } } - const removed: IChatData[] = []; + const removed: ISessionData[] = []; for (const [key, cached] of this._sessionCache) { if (!currentKeys.has(key)) { this._sessionCache.delete(key); @@ -425,7 +429,7 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess * Wait for a new session to appear in the cache that wasn't present before. * Tries an immediate refresh, then listens for the session-added notification. */ - private async _waitForNewSession(existingKeys: Set): Promise { + private async _waitForNewSession(existingKeys: Set): Promise { // First, try an immediate refresh await this._refreshSessions(CancellationToken.None); for (const [key, cached] of this._sessionCache) { @@ -435,7 +439,7 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess } // If not found yet, wait for the next onDidChangeSessions event - return new Promise(resolve => { + return new Promise(resolve => { const listener = this._onDidChangeSessions.event(e => { const newSession = e.added.find(s => { const rawId = s.resource.path.substring(1); diff --git a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 45da7d4b967d9..ed190e132b711 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -20,7 +20,7 @@ import { IChatService, type ChatSendResult } from '../../../../../workbench/cont import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; import { SessionStatus } from '../../../sessions/common/sessionData.js'; -import { IChatChangeEvent } from '../../../sessions/browser/sessionsProvider.js'; +import { ISessionChangeEvent } from '../../../sessions/browser/sessionsProvider.js'; import { CopilotCLISessionType } from '../../../sessions/browser/sessionTypes.js'; import { RemoteAgentHostSessionsProvider, type IRemoteAgentHostSessionsProviderConfig } from '../../browser/remoteAgentHostSessionsProvider.js'; @@ -194,8 +194,8 @@ suite('RemoteAgentHostSessionsProvider', () => { test('onDidChangeSessions fires when session added notification arrives', () => { const provider = createProvider(disposables, connection); - const changes: IChatChangeEvent[] = []; - disposables.add(provider.onDidChangeSessions((e: IChatChangeEvent) => changes.push(e))); + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e))); fireSessionAdded(connection, 'notif-1', { title: 'Notif Session' }); @@ -206,8 +206,8 @@ suite('RemoteAgentHostSessionsProvider', () => { test('accepts session notifications from any agent provider', () => { const provider = createProvider(disposables, connection); - const changes: IChatChangeEvent[] = []; - disposables.add(provider.onDidChangeSessions((e: IChatChangeEvent) => changes.push(e))); + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e))); fireSessionAdded(connection, 'other-sess', { provider: 'other-agent', title: 'Other Session' }); @@ -219,8 +219,8 @@ suite('RemoteAgentHostSessionsProvider', () => { const provider = createProvider(disposables, connection); fireSessionAdded(connection, 'to-remove', { title: 'Removed' }); - const changes: IChatChangeEvent[] = []; - disposables.add(provider.onDidChangeSessions((e: IChatChangeEvent) => changes.push(e))); + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e))); fireSessionRemoved(connection, 'to-remove'); @@ -230,8 +230,8 @@ suite('RemoteAgentHostSessionsProvider', () => { test('duplicate session added notification is ignored', () => { const provider = createProvider(disposables, connection); - const changes: IChatChangeEvent[] = []; - disposables.add(provider.onDidChangeSessions((e: IChatChangeEvent) => changes.push(e))); + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e))); fireSessionAdded(connection, 'dup-sess', { title: 'Dup' }); fireSessionAdded(connection, 'dup-sess', { title: 'Dup' }); @@ -241,8 +241,8 @@ suite('RemoteAgentHostSessionsProvider', () => { test('removing non-existent session is no-op', () => { const provider = createProvider(disposables, connection); - const changes: IChatChangeEvent[] = []; - disposables.add(provider.onDidChangeSessions((e: IChatChangeEvent) => changes.push(e))); + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e))); fireSessionRemoved(connection, 'does-not-exist'); @@ -254,8 +254,8 @@ suite('RemoteAgentHostSessionsProvider', () => { fireSessionAdded(connection, 'cross-prov', { provider: 'other-agent', title: 'Cross Provider' }); assert.strictEqual(provider.getSessions().length, 1); - const changes: IChatChangeEvent[] = []; - disposables.add(provider.onDidChangeSessions((e: IChatChangeEvent) => changes.push(e))); + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e))); fireSessionRemoved(connection, 'cross-prov', 'other-agent'); @@ -271,8 +271,8 @@ suite('RemoteAgentHostSessionsProvider', () => { connection.addSession(createSession('list-2', { summary: 'Second' })); const provider = createProvider(disposables, connection); - const changes: IChatChangeEvent[] = []; - disposables.add(provider.onDidChangeSessions((e: IChatChangeEvent) => changes.push(e))); + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e))); provider.getSessions(); await new Promise(resolve => setTimeout(resolve, 50)); @@ -325,7 +325,7 @@ suite('RemoteAgentHostSessionsProvider', () => { const target = sessions.find((s) => s.title.get() === 'To Delete'); assert.ok(target, 'Session should exist'); - await provider.deleteSession(target!.chatId); + await provider.deleteSession(target!.id); assert.strictEqual(connection.disposedSessions.length, 1); // The disposed URI must be a backend agent session URI (copilot://del-sess), @@ -347,7 +347,7 @@ suite('RemoteAgentHostSessionsProvider', () => { assert.ok(target, 'Session should exist'); assert.strictEqual(target!.isRead.get(), true); - provider.setRead(target!.chatId, false); + provider.setRead(target!.id, false); assert.strictEqual(target!.isRead.get(), false); }); @@ -417,8 +417,8 @@ suite('RemoteAgentHostSessionsProvider', () => { // Update on connection side connection.addSession(createSession('turn-sess', { summary: 'After', modifiedTime: 5000 })); - const changes: IChatChangeEvent[] = []; - disposables.add(provider.onDidChangeSessions((e: IChatChangeEvent) => changes.push(e))); + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e))); connection.fireAction({ action: { diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css index a8d1a3310c381..6136099f682de 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css @@ -61,6 +61,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-weight: 500; } /* Repository/folder label */ diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index 525e51a3ca50d..8af865126ea8e 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -5,7 +5,7 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { IObservable, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { IObservable, IReader, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; @@ -14,19 +14,18 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IAgentSession, isAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ISessionsProvidersService } from './sessionsProvidersService.js'; -import { ISessionType, ISendRequestOptions, IChatChangeEvent } from './sessionsProvider.js'; +import { ISessionType, ISendRequestOptions, ISessionChangeEvent } from './sessionsProvider.js'; import { SessionsGroupModel } from './sessionsGroupModel.js'; -import { ISessionData, ISessionWorkspace, GITHUB_REMOTE_FILE_SCHEME, IChatData } from '../common/sessionData.js'; +import { ISession, ISessionWorkspace, GITHUB_REMOTE_FILE_SCHEME, ISessionData, IChat, SessionStatus } from '../common/sessionData.js'; import { IGitHubSessionContext } from '../../github/common/types.js'; import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; /** * Configuration properties available on new/pending sessions. - * Not part of the public {@link ISessionData} contract but present on + * Not part of the public {@link ISession} contract but present on * concrete session implementations (CopilotCLISession, RemoteNewSession, AgentHostNewSession). */ @@ -53,9 +52,9 @@ const ACTIVE_PROVIDER_KEY = 'sessions.activeProviderId'; * Event fired when sessions change within a provider. */ export interface ISessionsChangeEvent { - readonly added: readonly ISessionData[]; - readonly removed: readonly ISessionData[]; - readonly changed: readonly ISessionData[]; + readonly added: readonly ISession[]; + readonly removed: readonly ISession[]; + readonly changed: readonly ISession[]; } /** @@ -71,12 +70,12 @@ export interface ISessionsManagementService { /** * Get all sessions from all registered providers. */ - getSessions(): ISessionData[]; + getSessions(): ISession[]; /** * Get a session by its resource URI. */ - getSession(resource: URI): ISessionData | undefined; + getSession(resource: URI): ISession | undefined; /** * Get all session types from all registered providers. @@ -96,9 +95,9 @@ export interface ISessionsManagementService { // -- Active Session -- /** - * Observable for the currently active session as {@link ISessionData}. + * Observable for the currently active session as {@link ISession}. */ - readonly activeSession: IObservable; + readonly activeSession: IObservable; /** * Observable for the currently active sessions provider ID. @@ -138,36 +137,30 @@ export interface ISessionsManagementService { * Create a new session for the given workspace. * Delegates to the provider identified by providerId. */ - createNewSession(providerId: string, workspace: ISessionWorkspace): ISessionData; + createNewSession(providerId: string, workspace: ISessionWorkspace): ISession; /** * Send a request to an existing session */ - sendAndCreateChat(options: ISendRequestOptions, session: ISessionData): Promise; + sendAndCreateChat(options: ISendRequestOptions, session: ISession): Promise; /** * Send the initial request for a session. */ - sendRequest(chat: IChatData, options: ISendRequestOptions, session?: ISessionData): Promise; + sendRequest(chat: IChat, options: ISendRequestOptions, session?: ISession): Promise; /** * Update the session type for a new session. * The provider may recreate the session object. * If the session is the active session, the active session data is updated. */ - setSessionType(chat: IChatData, type: ISessionType): Promise; - - /** - * Commit files in a worktree and refresh the agent sessions model - * so the Changes view reflects the update. - */ - commitWorktreeFiles(session: ISessionData, fileUris: URI[]): Promise; + setSessionType(chat: IChat, type: ISessionType): Promise; /** * Derive a GitHub context (owner, repo, prNumber) from an active session. * Returns `undefined` if the session is not associated with a GitHub repository. */ - getGitHubContext(session: ISessionData): IGitHubSessionContext | undefined; + getGitHubContext(session: ISession): IGitHubSessionContext | undefined; /** * Derive a GitHub context from a session resource URI. @@ -183,22 +176,46 @@ export interface ISessionsManagementService { // -- Session Actions -- /** Archive a session. */ - archiveSession(session: ISessionData): Promise; + archiveSession(session: ISession): Promise; /** Unarchive a session. */ - unarchiveSession(session: ISessionData): Promise; + unarchiveSession(session: ISession): Promise; /** Delete a session. */ - deleteSession(session: ISessionData): Promise; + deleteSession(session: ISession): Promise; /** Delete a single chat from a session. */ - deleteChat(chat: IChatData): Promise; + deleteChat(chat: IChat): Promise; /** Rename a chat. */ - renameChat(chat: IChatData, title: string): Promise; + renameChat(chat: IChat, title: string): Promise; /** Mark a session as read or unread. */ - setRead(session: ISessionData, read: boolean): void; + setRead(session: ISession, read: boolean): void; } export const ISessionsManagementService = createDecorator('sessionsManagementService'); -function latestDateAcrossChats(chats: readonly IChatData[], getter: (chat: IChatData) => Date | undefined): Date | undefined { +function toChat(data: ISessionData): IChat { + return { + chatId: data.id, + resource: data.resource, + providerId: data.providerId, + sessionType: data.sessionType, + icon: data.icon, + createdAt: data.createdAt, + workspace: data.workspace, + title: data.title, + updatedAt: data.updatedAt, + status: data.status, + changes: data.changes, + modelId: data.modelId, + mode: data.mode, + loading: data.loading, + isArchived: data.isArchived, + isRead: data.isRead, + description: data.description, + lastTurnEnd: data.lastTurnEnd, + pullRequest: data.pullRequest, + }; +} + +function latestDateAcrossChats(chats: readonly IChat[], getter: (chat: IChat) => Date | undefined): Date | undefined { let latest: Date | undefined; for (const chat of chats) { const d = getter(chat); @@ -209,6 +226,20 @@ function latestDateAcrossChats(chats: readonly IChatData[], getter: (chat: IChat return latest; } +function aggregateStatusAcrossChats(chats: readonly IChat[], reader: IReader): SessionStatus { + for (const c of chats) { + if (c.status.read(reader) === SessionStatus.NeedsInput) { + return SessionStatus.NeedsInput; + } + } + for (const c of chats) { + if (c.status.read(reader) === SessionStatus.InProgress) { + return SessionStatus.InProgress; + } + } + return chats[0].status.read(reader); +} + export class SessionsManagementService extends Disposable implements ISessionsManagementService { declare readonly _serviceBrand: undefined; @@ -221,8 +252,8 @@ export class SessionsManagementService extends Disposable implements ISessionsMa private _sessionTypes: readonly ISessionType[] = []; - private readonly _activeSession = observableValue(this, undefined); - readonly activeSession: IObservable = this._activeSession; + private readonly _activeSession = observableValue(this, undefined); + readonly activeSession: IObservable = this._activeSession; private readonly _activeProviderId = observableValue(this, undefined); readonly activeProviderId: IObservable = this._activeProviderId; private lastSelectedSession: URI | undefined; @@ -231,14 +262,13 @@ export class SessionsManagementService extends Disposable implements ISessionsMa private readonly _activeSessionType: IContextKey; private readonly _isBackgroundProvider: IContextKey; private readonly _groupModel: SessionsGroupModel; - private readonly _sessionDataCache = new Map(); + private readonly _sessionDataCache = new Map(); constructor( @IStorageService private readonly storageService: IStorageService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @ILogService private readonly logService: ILogService, @IContextKeyService contextKeyService: IContextKeyService, - @ICommandService private readonly commandService: ICommandService, @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @@ -285,13 +315,13 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } // Find the chat data matching this session resource - const chat = this._getChat(sessionResource); + const chat = this._getSessionData(sessionResource); if (!chat) { return; } // Update the group model's active chat - this._groupModel.setActiveChatId(chat.chatId); + this._groupModel.setActiveChatId(chat.id); } private _initActiveProvider(): void { @@ -323,15 +353,15 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } /** - * Convert an array of chats into deduplicated sessions using the group model. - * Multiple chats may map to the same session group; this returns one - * {@link ISessionData} per unique group. + * Convert an array of session data into deduplicated sessions using the group model. + * Multiple session data entries may map to the same session group; this returns one + * {@link ISession} per unique group. */ - private _chatsToSessions(chats: readonly IChatData[]): ISessionData[] { + private _sessionDataToSessions(chats: readonly ISessionData[]): ISession[] { const seen = new Set(); - const sessions: ISessionData[] = []; + const sessions: ISession[] = []; for (const chat of chats) { - const groupId = this._groupModel.getSessionIdForChat(chat.chatId) ?? chat.chatId; + const groupId = this._groupModel.getSessionIdForChat(chat.id) ?? chat.id; if (!seen.has(groupId)) { seen.add(groupId); sessions.push(this._chatToSession(chat)); @@ -340,19 +370,19 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return sessions; } - private onDidChangeSessionsFromSessionsProviders(e: IChatChangeEvent): void { + private onDidChangeSessionsFromSessionsProviders(e: ISessionChangeEvent): void { const sessionEvent: ISessionsChangeEvent = { - added: this._chatsToSessions(e.added), - removed: this._chatsToSessions(e.removed), - changed: this._chatsToSessions(e.changed), + added: this._sessionDataToSessions(e.added), + removed: this._sessionDataToSessions(e.removed), + changed: this._sessionDataToSessions(e.changed), }; this._onDidChangeSessions.fire(sessionEvent); const currentActive = this._activeSession.get(); // Remove chats from the group model and clean up session cache for (const removed of e.removed) { - const sessionId = this._groupModel.getSessionIdForChat(removed.chatId); - this._groupModel.removeChat(removed.chatId); + const sessionId = this._groupModel.getSessionIdForChat(removed.id); + this._groupModel.removeChat(removed.id); if (sessionId && this._groupModel.getChatIds(sessionId).length === 0) { this._sessionDataCache.delete(sessionId); } @@ -363,7 +393,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } if (e.removed.length) { - if (e.removed.some(r => currentActive.chats.get().find(c => c.chatId === r.chatId))) { + if (e.removed.some(r => currentActive.chats.get().find(c => c.chatId === r.id))) { // Only open new session view if the group has no remaining chats if (this._groupModel.getChatIds(currentActive.sessionId).length === 0) { this.openNewSessionView(); @@ -373,14 +403,14 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } if (e.changed.length) { - const updated = e.changed.find(s => currentActive.chats.get().find(c => c.chatId === s.chatId)); + const updated = e.changed.find(s => currentActive.chats.get().find(c => c.chatId === s.id)); if (updated?.isArchived.get()) { // Only open new session view if all chats in the group are archived const groupId = this._groupModel.getSessionIdForChat(currentActive.sessionId); const chatIds = groupId ? this._groupModel.getChatIds(groupId) : []; const allChats = this.sessionsProvidersService.getSessions(); const allArchived = chatIds.length === 0 || chatIds.every(id => { - const chat = allChats.find(c => c.chatId === id); + const chat = allChats.find(c => c.id === id); return !chat || chat.isArchived.get(); }); if (allArchived) { @@ -433,21 +463,21 @@ export class SessionsManagementService extends Disposable implements ISessionsMa worktreeBaseBranchProtected]; } - getSessions(): ISessionData[] { + getSessions(): ISession[] { const allChats = this.sessionsProvidersService.getSessions(); - const chatById = new Map(); + const chatById = new Map(); for (const chat of allChats) { - chatById.set(chat.chatId, chat); + chatById.set(chat.id, chat); } - const groupedChats = new Map(); + const groupedChats = new Map(); for (const chat of allChats) { - let groupId = this._groupModel.getSessionIdForChat(chat.chatId); + let groupId = this._groupModel.getSessionIdForChat(chat.id); if (!groupId) { // No group exists — create a single-chat group - groupId = chat.chatId; - this._groupModel.addChat(groupId, chat.chatId); + groupId = chat.id; + this._groupModel.addChat(groupId, chat.id); } if (!groupedChats.has(groupId)) { groupedChats.set(groupId, []); @@ -455,7 +485,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } // Order chats within each group according to the group model - const sessions: ISessionData[] = []; + const sessions: ISession[] = []; for (const [groupId, chats] of groupedChats) { const orderedChatIds = this._groupModel.getChatIds(groupId); for (const chatId of orderedChatIds) { @@ -471,13 +501,13 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return sessions; } - private _getChat(resource: URI): IChatData | undefined { + private _getSessionData(resource: URI): ISessionData | undefined { return this.sessionsProvidersService.getSessions().find(s => this.uriIdentityService.extUri.isEqual(s.resource, resource)); } - getSession(resource: URI): ISessionData | undefined { - const chat = this._getChat(resource); - return chat ? this._chatToSession(chat) : undefined; + getSession(resource: URI): ISession | undefined { + const sessionData = this._getSessionData(resource); + return sessionData ? this._chatToSession(sessionData) : undefined; } getAllSessionTypes(): ISessionType[] { @@ -510,7 +540,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa async openChat(chatResource: URI): Promise { const sessionData = this.getSession(chatResource); - const chat = this._getChat(chatResource); + const chat = this._getSessionData(chatResource); this.logService.info(`[SessionsManagement] openChat: ${chatResource.toString()} provider=${chat?.providerId}`); this.isNewChatSessionContext.set(false); @@ -533,7 +563,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa await this.chatWidgetService.openSession(activeChatResource, ChatViewPaneTarget, { preserveFocus: options?.preserveFocus }); } - createNewSession(providerId: string, workspace: ISessionWorkspace): ISessionData { + createNewSession(providerId: string, workspace: ISessionWorkspace): ISession { if (!this.isNewChatSessionContext.get()) { this.isNewChatSessionContext.set(true); } @@ -550,7 +580,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return sessionData; } - async setSessionType(chat: IChatData, type: ISessionType): Promise { + async setSessionType(chat: IChat, type: ISessionType): Promise { const provider = this.sessionsProvidersService.getProviders().find(p => p.id === chat.providerId); if (!provider) { throw new Error(`Sessions provider '${chat.providerId}' not found`); @@ -565,7 +595,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } } - async sendAndCreateChat(options: ISendRequestOptions, session: ISessionData): Promise { + async sendAndCreateChat(options: ISendRequestOptions, session: ISession): Promise { const provider = this.sessionsProvidersService.getProviders().find(p => p.id === session.providerId); if (!provider) { throw new Error(`Sessions provider '${session.providerId}' not found`); @@ -573,23 +603,23 @@ export class SessionsManagementService extends Disposable implements ISessionsMa const chatData = provider.createNewSessionFrom(session.chats.get()[0].chatId); - const newChat = await provider.sendRequest(chatData.chatId, options); + const newChat = await provider.sendRequest(chatData.id, options); // Set the new agent session as active if (newChat) { // It's likely that the provider has already added the new chat to the group before provider.sendRequest returns. // This will cause a new group to be created for the new chat which actually belongs to the same session. - if (this._groupModel.hasGroupForSession(newChat.chatId)) { - this._groupModel.deleteSession(newChat.chatId); + if (this._groupModel.hasGroupForSession(newChat.id)) { + this._groupModel.deleteSession(newChat.id); } // Add the new chat to the session's group - this._groupModel.addChat(session.sessionId, newChat.chatId); + this._groupModel.addChat(session.sessionId, newChat.id); } this._onDidChangeSessions.fire({ added: [], removed: [], changed: [session] }); } - async sendRequest(chat: IChatData, options: ISendRequestOptions): Promise { + async sendRequest(chat: IChat, options: ISendRequestOptions): Promise { this.isNewChatSessionContext.set(false); const provider = this.sessionsProvidersService.getProviders().find(p => p.id === chat.providerId); @@ -603,7 +633,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa // Set the new agent session as active if (newChat) { // Add the new chat to the session's group - this._groupModel.addChat(newChat.chatId, newChat.chatId); + this._groupModel.addChat(newChat.id, newChat.id); this.setActiveSession(this._chatToSession(newChat)); } } @@ -622,7 +652,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return repositoryUri; } - private setActiveSession(session: ISessionData | undefined): void { + private setActiveSession(session: ISession | undefined): void { // Update context keys from session data this._activeSessionProviderId.set(session?.providerId ?? ''); this._activeSessionType.set(session?.sessionType ?? ''); @@ -641,21 +671,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa this._activeSession.set(session, undefined); } - async commitWorktreeFiles(session: ISessionData, fileUris: URI[]): Promise { - const worktreeUri = session.workspace.get()?.repositories[0]?.workingDirectory; - if (!worktreeUri) { - throw new Error('Cannot commit worktree files: active session has no associated worktree'); - } - for (const fileUri of fileUris) { - await this.commandService.executeCommand( - 'github.copilot.cli.sessions.commitToWorktree', - { worktreeUri, fileUri } - ); - } - await this.agentSessionsService.model.resolve(AgentSessionProviders.Background); - } - - getGitHubContext(session: ISessionData): IGitHubSessionContext | undefined { + getGitHubContext(session: ISession): IGitHubSessionContext | undefined { // 1. Try parsing a github-remote-file URI (Cloud sessions) const repoUri = session.workspace.get()?.repositories[0]?.uri; if (repoUri && repoUri.scheme === GITHUB_REMOTE_FILE_SCHEME) { @@ -701,7 +717,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } getGitHubContextForSession(sessionResource: URI): IGitHubSessionContext | undefined { - // Try finding the ISessionData first (preferred path) + // Try finding the ISession first (preferred path) const sessionData = this.getSession(sessionResource); if (sessionData) { return this.getGitHubContext(sessionData); @@ -735,7 +751,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return URI.joinPath(baseUri, relativePath); } - private _parsePRNumberFromSession(session: ISessionData): number | undefined { + private _parsePRNumberFromSession(session: ISession): number | undefined { const prUri = session.pullRequest.get()?.uri; if (prUri) { const match = /\/pull\/(\d+)/.exec(prUri.path); @@ -747,15 +763,15 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } /** - * Wraps a primary {@link IChatData} and its sibling chats into an {@link ISessionData}. - * Uses `Object.create` so that all properties of the primary chat are inherited + * Wraps a primary {@link ISessionData} and its sibling sessions into an {@link ISession}. + * Uses `Object.create` so that all properties of the primary session are inherited * through the prototype chain, avoiding issues with class getters. * * The `chats` and `activeChat` observables are derived from the group model * and update automatically when the group model fires a change event. */ - private _chatToSession(chat: IChatData): ISessionData { - const sessionId = this._groupModel.getSessionIdForChat(chat.chatId) ?? chat.chatId; + private _chatToSession(chat: ISessionData): ISession { + const sessionId = this._groupModel.getSessionIdForChat(chat.id) ?? chat.id; /* const cached = this._sessionDataCache.get(sessionId); if (cached) { @@ -768,22 +784,22 @@ export class SessionsManagementService extends Disposable implements ISessionsMa () => { const chatIds = this._groupModel.getChatIds(sessionId); if (chatIds.length === 0) { - return [chat]; + return [toChat(chat)]; } const provider = this.sessionsProvidersService.getProviders().find(p => p.id === chat.providerId); const providerChats = provider?.getSessions() || []; - const chatById = new Map(providerChats.map(c => [c.chatId, c])); + const chatById = new Map(providerChats.map(c => [c.id, c])); const chatOrder = new Map(chatIds.map((id, index) => [id, index])); - const resolved = chatIds.map(id => chatById.get(id)).filter((c): c is IChatData => !!c); + const resolved = chatIds.map(id => chatById.get(id)).filter((c): c is ISessionData => !!c); if (resolved.length === 0) { - return [chat]; + return [toChat(chat)]; } - return resolved.sort((a, b) => (chatOrder.get(a.chatId) ?? Infinity) - (chatOrder.get(b.chatId) ?? Infinity)); + return resolved.sort((a, b) => (chatOrder.get(a.id) ?? Infinity) - (chatOrder.get(b.id) ?? Infinity)).map(toChat); }, ); const activeChatObs = chatsObs.map(chats => { if (!this._groupModel.hasGroupForSession(sessionId)) { - return chat; //new Sessions might not be in the group model + return toChat(chat); //new Sessions might not be in the group model } const activeChatId = this._groupModel.getActiveChatId(sessionId); const activeChat = chats.find(c => c.chatId === activeChatId); @@ -793,15 +809,19 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return activeChat; }); - const updatedAtObs = chatsObs.map(chats => latestDateAcrossChats(chats, c => c.updatedAt.get())!); - const lastTurnEndObs = chatsObs.map(chats => latestDateAcrossChats(chats, c => c.lastTurnEnd.get())); + const updatedAtObs = chatsObs.map((chats, reader) => latestDateAcrossChats(chats, c => c.updatedAt.read(reader))!); + const lastTurnEndObs = chatsObs.map((chats, reader) => latestDateAcrossChats(chats, c => c.lastTurnEnd.read(reader))); + const statusObs = chatsObs.map((chats, reader) => aggregateStatusAcrossChats(chats, reader)); + const isReadObs = chatsObs.map((chats, reader) => chats.every(c => c.isRead.read(reader))); const mainChat = chatsObs.get()[0]; - const sessionData: ISessionData = { + const sessionData: ISession = { ...mainChat, // Inherit properties from the primary chat sessionId, + status: statusObs, updatedAt: updatedAtObs, lastTurnEnd: lastTurnEndObs, + isRead: isReadObs, chats: chatsObs, activeChat: activeChatObs, mainChat, @@ -831,19 +851,19 @@ export class SessionsManagementService extends Disposable implements ISessionsMa // -- Session Actions -- - async archiveSession(session: ISessionData): Promise { + async archiveSession(session: ISession): Promise { for (const chat of session.chats.get()) { await this.sessionsProvidersService.archiveSession(chat.chatId); } } - async unarchiveSession(session: ISessionData): Promise { + async unarchiveSession(session: ISession): Promise { for (const chat of session.chats.get()) { await this.sessionsProvidersService.unarchiveSession(chat.chatId); } } - async deleteSession(session: ISessionData): Promise { + async deleteSession(session: ISession): Promise { this._sessionDataCache.delete(session.sessionId); for (const chat of session.chats.get()) { // Clear the chat widget before removing from storage @@ -852,7 +872,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } } - async deleteChat(chat: IChatData): Promise { + async deleteChat(chat: IChat): Promise { const session = this.getSession(chat.resource); if (!session) { throw new Error(`Session for chat ${chat.chatId} not found`); @@ -867,11 +887,11 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } } - async renameChat(chat: IChatData, title: string): Promise { + async renameChat(chat: IChat, title: string): Promise { await this.sessionsProvidersService.renameSession(chat.chatId, title); } - setRead(session: ISessionData, read: boolean): void { + setRead(session: ISession, read: boolean): void { for (const chat of session.chats.get()) { this.sessionsProvidersService.setRead(chat.chatId, read); } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsProvider.ts b/src/vs/sessions/contrib/sessions/browser/sessionsProvider.ts index ea77414dcef5b..33ce6c53d92ff 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsProvider.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsProvider.ts @@ -6,7 +6,7 @@ import { Event } from '../../../../base/common/event.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; -import { IChatData, ISessionWorkspace } from '../common/sessionData.js'; +import { ISessionData, ISessionWorkspace } from '../common/sessionData.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; /** @@ -40,10 +40,10 @@ export interface ISessionsBrowseAction { /** * Event fired when sessions change within a provider. */ -export interface IChatChangeEvent { - readonly added: readonly IChatData[]; - readonly removed: readonly IChatData[]; - readonly changed: readonly IChatData[]; +export interface ISessionChangeEvent { + readonly added: readonly ISessionData[]; + readonly removed: readonly ISessionData[]; + readonly changed: readonly ISessionData[]; } /** @@ -83,20 +83,20 @@ export interface ISessionsProvider { // -- Sessions (existing) -- /** Returns all chats owned by this provider. */ - getSessions(): IChatData[]; + getSessions(): ISessionData[]; /** Fires when chats are added, removed, or changed. */ - readonly onDidChangeSessions: Event; + readonly onDidChangeSessions: Event; // -- Session Management -- /** Create a new session for the given workspace. */ - createNewSession(workspace: ISessionWorkspace): IChatData; + createNewSession(workspace: ISessionWorkspace): ISessionData; - createNewSessionFrom(chatId: string): IChatData; + createNewSessionFrom(chatId: string): ISessionData; /** Update the session type for a session. */ - setSessionType(chatId: string, type: ISessionType): IChatData; + setSessionType(chatId: string, type: ISessionType): ISessionData; /** Returns session types available for the given session. */ - getSessionTypes(chat: IChatData): ISessionType[]; + getSessionTypes(session: ISessionData): ISessionType[]; /** Rename a session. */ renameSession(chatId: string, title: string): Promise; /** Set the model for a session. */ @@ -110,8 +110,13 @@ export interface ISessionsProvider { /** Mark a session as read or unread. */ setRead(chatId: string, read: boolean): void; + // -- Untitled -- + + /** Returns the current untitled (not yet sent) session, if any. */ + getUntitledSession(): ISessionData | undefined; // TODO: Shoulds ideally be removed when new chat and picker is cleaned up + // -- Send -- /** Send the initial request for a new session. Returns the created chat data. */ - sendRequest(chatId: string, options: ISendRequestOptions): Promise; + sendRequest(chatId: string, options: ISendRequestOptions): Promise; } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsProvidersService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsProvidersService.ts index 980285e25e6af..93c9645a50ff0 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsProvidersService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsProvidersService.ts @@ -7,8 +7,8 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { IChatData, ISessionWorkspace } from '../common/sessionData.js'; -import { IChatChangeEvent, ISessionsProvider, ISessionType } from './sessionsProvider.js'; +import { ISessionData, ISessionWorkspace } from '../common/sessionData.js'; +import { ISessionChangeEvent, ISessionsProvider, ISessionType } from './sessionsProvider.js'; import { URI } from '../../../../base/common/uri.js'; export const ISessionsProvidersService = createDecorator('sessionsProvidersService'); @@ -35,16 +35,16 @@ export interface ISessionsProvidersService { /** Get available session types for a provider. */ getSessionTypesForProvider(providerId: string): ISessionType[]; /** Get available session types for a session from its provider. */ - getSessionTypes(session: IChatData): ISessionType[]; + getSessionTypes(session: ISessionData): ISessionType[]; // -- Aggregated Sessions -- /** Get all chats from all providers. */ - getSessions(): IChatData[]; + getSessions(): ISessionData[]; /** Get a chat by its globally unique ID. */ - getSession(chatId: string): IChatData | undefined; + getSession(chatId: string): ISessionData | undefined; /** Fires when sessions change across any provider. */ - readonly onDidChangeSessions: Event; + readonly onDidChangeSessions: Event; // -- Session Actions (routed to the correct provider via sessionId) -- @@ -60,6 +60,8 @@ export interface ISessionsProvidersService { setRead(sessionId: string, read: boolean): void; /** Resolve a repository URI to a session workspace using the given provider. */ resolveWorkspace(providerId: string, repositoryUri: URI): ISessionWorkspace | undefined; + /** Returns the current untitled session for the given provider, if any. */ + getUntitledSession(providerId: string): ISessionData | undefined; // TODO: Shoulds ideally be removed when new chat and picker is cleaned up } /** @@ -75,8 +77,8 @@ export class SessionsProvidersService extends Disposable implements ISessionsPro private readonly _onDidChangeProviders = this._register(new Emitter()); readonly onDidChangeProviders: Event = this._onDidChangeProviders.event; - private readonly _onDidChangeSessions = this._register(new Emitter()); - readonly onDidChangeSessions: Event = this._onDidChangeSessions.event; + private readonly _onDidChangeSessions = this._register(new Emitter()); + readonly onDidChangeSessions: Event = this._onDidChangeSessions.event; // -- Provider Registry -- @@ -119,7 +121,7 @@ export class SessionsProvidersService extends Disposable implements ISessionsPro return [...entry.provider.sessionTypes]; } - getSessionTypes(session: IChatData): ISessionType[] { + getSessionTypes(session: ISessionData): ISessionType[] { const entry = this._providers.get(session.providerId); if (!entry) { return []; @@ -129,20 +131,20 @@ export class SessionsProvidersService extends Disposable implements ISessionsPro // -- Aggregated Sessions -- - getSessions(): IChatData[] { - const sessions: IChatData[] = []; + getSessions(): ISessionData[] { + const sessions: ISessionData[] = []; for (const { provider } of this._providers.values()) { sessions.push(...provider.getSessions()); } return sessions; } - getSession(chatId: string): IChatData | undefined { + getSession(chatId: string): ISessionData | undefined { const { provider } = this._resolveProvider(chatId); if (!provider) { return undefined; } - return provider.getSessions().find(s => s.chatId === chatId); + return provider.getSessions().find(s => s.id === chatId); } // -- Session Actions -- @@ -187,6 +189,11 @@ export class SessionsProvidersService extends Disposable implements ISessionsPro return entry?.provider.resolveWorkspace(repositoryUri); } + getUntitledSession(providerId: string): ISessionData | undefined { + const entry = this._providers.get(providerId); + return entry?.provider.getUntitledSession(); + } + // -- Private Helpers -- /** diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index a78bfe8bc13c3..b258a99636129 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -30,7 +30,7 @@ import { WorkbenchObjectTree } from '../../../../../platform/list/browser/listSe import { IStyleOverride, defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { asCssVariable } from '../../../../../platform/theme/common/colorUtils.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; -import { GITHUB_REMOTE_FILE_SCHEME, ISessionData, ISessionWorkspace, SessionStatus } from '../../common/sessionData.js'; +import { GITHUB_REMOTE_FILE_SCHEME, ISession, ISessionWorkspace, SessionStatus } from '../../common/sessionData.js'; import { ISessionsManagementService } from '../sessionsManagementService.js'; import { AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; @@ -63,7 +63,7 @@ export enum SessionsSorting { export interface ISessionSection { readonly id: string; readonly label: string; - readonly sessions: ISessionData[]; + readonly sessions: ISession[]; } export interface ISessionShowMore { @@ -72,7 +72,7 @@ export interface ISessionShowMore { readonly remainingCount: number; } -export type SessionListItem = ISessionData | ISessionSection | ISessionShowMore; +export type SessionListItem = ISession | ISessionSection | ISessionShowMore; function isSessionSection(item: SessionListItem): item is ISessionSection { return 'sessions' in item && Array.isArray((item as ISessionSection).sessions); @@ -103,7 +103,7 @@ class SessionsTreeDelegate implements IListVirtualDelegate { let height = SessionsTreeDelegate.ITEM_HEIGHT; if (this._approvalModel) { - const approval = getFirstApprovalAcrossChats(this._approvalModel, element as ISessionData, undefined); + const approval = getFirstApprovalAcrossChats(this._approvalModel, element as ISession, undefined); if (approval) { height += SessionItemRenderer.getApprovalRowHeight(approval.label); } @@ -157,11 +157,11 @@ class SessionItemRenderer implements ITreeRenderer(); - readonly onDidChangeItemHeight: Event = this._onDidChangeItemHeight.event; + private readonly _onDidChangeItemHeight = new Emitter(); + readonly onDidChangeItemHeight: Event = this._onDidChangeItemHeight.event; constructor( - private readonly options: { grouping: () => SessionsGrouping; sorting: () => SessionsSorting; isPinned: (session: ISessionData) => boolean }, + private readonly options: { grouping: () => SessionsGrouping; sorting: () => SessionsSorting; isPinned: (session: ISession) => boolean }, private readonly approvalModel: AgentSessionApprovalModel | undefined, private readonly instantiationService: IInstantiationService, private readonly contextKeyService: IContextKeyService, @@ -203,7 +203,7 @@ class SessionItemRenderer implements ITreeRenderer; - private sessions: ISessionData[] = []; + private sessions: ISession[] = []; private visible = true; private readonly _pinnedSessionIds: Set; private readonly excludedSessionTypes: Set; @@ -791,9 +791,9 @@ export class SessionsList extends Disposable implements ISessionsList { const sorted = this.sortSessions(filtered); // Separate pinned and archived sessions (archived always wins over pinned) - const pinned: ISessionData[] = []; - const archived: ISessionData[] = []; - const regular: ISessionData[] = []; + const pinned: ISession[] = []; + const archived: ISession[] = []; + const regular: ISession[] = []; for (const session of sorted) { if (session.isArchived.get()) { archived.push(session); @@ -950,19 +950,19 @@ export class SessionsList extends Disposable implements ISessionsList { // -- Pinning -- - pinSession(session: ISessionData): void { + pinSession(session: ISession): void { this._pinnedSessionIds.add(session.sessionId); this.savePinnedSessions(); this.update(); } - unpinSession(session: ISessionData): void { + unpinSession(session: ISession): void { this._pinnedSessionIds.delete(session.sessionId); this.savePinnedSessions(); this.update(); } - isSessionPinned(session: ISessionData): boolean { + isSessionPinned(session: ISession): boolean { return this._pinnedSessionIds.has(session.sessionId); } @@ -1155,17 +1155,17 @@ export class SessionsList extends Disposable implements ISessionsList { // -- Sorting -- - private sortSessions(sessions: ISessionData[]): ISessionData[] { + private sortSessions(sessions: ISession[]): ISession[] { return sortSessions(sessions, this.options.sorting()); } // -- Grouping -- - private groupByWorkspace(sessions: ISessionData[]): ISessionSection[] { + private groupByWorkspace(sessions: ISession[]): ISessionSection[] { return groupByWorkspace(sessions); } - private groupByDate(sessions: ISessionData[]): ISessionSection[] { + private groupByDate(sessions: ISession[]): ISessionSection[] { return groupByDate(sessions, this.options.sorting()); } } @@ -1174,7 +1174,7 @@ export class SessionsList extends Disposable implements ISessionsList { //#region Approval Helpers -function getFirstApprovalAcrossChats(approvalModel: AgentSessionApprovalModel, session: ISessionData, reader: IReader | undefined,): IAgentSessionApprovalInfo | undefined { +function getFirstApprovalAcrossChats(approvalModel: AgentSessionApprovalModel, session: ISession, reader: IReader | undefined,): IAgentSessionApprovalInfo | undefined { let oldest: IAgentSessionApprovalInfo | undefined; for (const chat of session.chats.read(reader)) { const approval = approvalModel.getApproval(chat.resource).read(reader); @@ -1189,7 +1189,7 @@ function getFirstApprovalAcrossChats(approvalModel: AgentSessionApprovalModel, s //#region Sorting & Grouping Helpers -export function sortSessions(sessions: ISessionData[], sorting: SessionsSorting): ISessionData[] { +export function sortSessions(sessions: ISession[], sorting: SessionsSorting): ISession[] { return [...sessions].sort((a, b) => { if (sorting === SessionsSorting.Updated) { return b.updatedAt.get().getTime() - a.updatedAt.get().getTime(); @@ -1198,8 +1198,8 @@ export function sortSessions(sessions: ISessionData[], sorting: SessionsSorting) }); } -export function groupByWorkspace(sessions: ISessionData[]): ISessionSection[] { - const groups = new Map(); +export function groupByWorkspace(sessions: ISession[]): ISessionSection[] { + const groups = new Map(); for (const session of sessions) { const workspace = session.workspace.get(); const label = workspace?.label ?? localize('unknown', "Unknown"); @@ -1231,16 +1231,16 @@ export function groupByWorkspace(sessions: ISessionData[]): ISessionSection[] { return result; } -export function groupByDate(sessions: ISessionData[], sorting: SessionsSorting): ISessionSection[] { +export function groupByDate(sessions: ISession[], sorting: SessionsSorting): ISessionSection[] { const now = new Date(); const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); const startOfYesterday = startOfToday - 86_400_000; const startOfWeek = startOfToday - 7 * 86_400_000; - const today: ISessionData[] = []; - const yesterday: ISessionData[] = []; - const week: ISessionData[] = []; - const older: ISessionData[] = []; + const today: ISession[] = []; + const yesterday: ISession[] = []; + const week: ISession[] = []; + const older: ISession[] = []; for (const session of sessions) { const time = sorting === SessionsSorting.Updated @@ -1259,7 +1259,7 @@ export function groupByDate(sessions: ISessionData[], sorting: SessionsSorting): } const sections: ISessionSection[] = []; - const addGroup = (id: string, label: string, groupSessions: ISessionData[]) => { + const addGroup = (id: string, label: string, groupSessions: ISession[]) => { if (groupSessions.length > 0) { sections.push({ id, label, sessions: groupSessions }); } diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index 0863a1c34524e..e61aab4d12b1c 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -20,7 +20,7 @@ import { AUX_WINDOW_GROUP } from '../../../../../workbench/services/editor/commo import { SessionsCategories } from '../../../../common/categories.js'; import { SessionItemToolbarMenuId, SessionItemContextMenuId, SessionSectionToolbarMenuId, SessionSectionTypeContext, IsSessionPinnedContext, IsSessionArchivedContext, IsSessionReadContext, SessionsGrouping, SessionsSorting, ISessionSection } from './sessionsList.js'; import { ISessionsManagementService, IsNewChatSessionContext } from '../sessionsManagementService.js'; -import { ISessionData, SessionStatus } from '../../common/sessionData.js'; +import { ISession, SessionStatus } from '../../common/sessionData.js'; import { IsWorkspaceGroupCappedContext, SessionsViewFilterOptionsSubMenu, SessionsViewFilterSubMenu, SessionsViewGroupingContext, SessionsViewId, SessionsView, SessionsViewSortingContext } from './sessionsView.js'; import { SessionsViewId as NewChatViewId, NewChatViewPane } from '../../../chat/browser/newChatViewPane.js'; import { Menus } from '../../../../browser/menus.js'; @@ -246,7 +246,7 @@ registerAction2(class NewSessionForWorkspaceAction extends Action2 { super({ id: 'sessionsView.sectionNewSession', title: localize2('newSessionForWorkspace', "New Session"), - icon: Codicon.newSession, + icon: Codicon.plus, menu: [{ id: SessionSectionToolbarMenuId, group: 'navigation', @@ -400,7 +400,7 @@ registerAction2(class PinSessionAction extends Action2 { }] }); } - run(accessor: ServicesAccessor, context?: ISessionData): void { + run(accessor: ServicesAccessor, context?: ISession): void { if (!context) { return; } @@ -435,7 +435,7 @@ registerAction2(class UnpinSessionAction extends Action2 { }] }); } - run(accessor: ServicesAccessor, context?: ISessionData): void { + run(accessor: ServicesAccessor, context?: ISession): void { if (!context) { return; } @@ -464,7 +464,7 @@ registerAction2(class ArchiveSessionAction extends Action2 { }] }); } - async run(accessor: ServicesAccessor, context?: ISessionData): Promise { + async run(accessor: ServicesAccessor, context?: ISession): Promise { if (!context) { return; } @@ -492,7 +492,7 @@ registerAction2(class UnarchiveSessionAction extends Action2 { }] }); } - async run(accessor: ServicesAccessor, context?: ISessionData): Promise { + async run(accessor: ServicesAccessor, context?: ISession): Promise { if (!context) { return; } @@ -517,7 +517,7 @@ registerAction2(class MarkSessionReadAction extends Action2 { }] }); } - run(accessor: ServicesAccessor, context?: ISessionData): void { + run(accessor: ServicesAccessor, context?: ISession): void { if (!context) { return; } @@ -542,7 +542,7 @@ registerAction2(class MarkSessionUnreadAction extends Action2 { }] }); } - run(accessor: ServicesAccessor, context?: ISessionData): void { + run(accessor: ServicesAccessor, context?: ISession): void { if (!context) { return; } @@ -563,7 +563,7 @@ registerAction2(class OpenSessionInNewWindowAction extends Action2 { }] }); } - async run(accessor: ServicesAccessor, context?: ISessionData): Promise { + async run(accessor: ServicesAccessor, context?: ISession): Promise { if (!context) { return; } @@ -634,7 +634,7 @@ registerAction2(class AddChatAction extends Action2 { super({ id: 'agentSession.addChat', title: localize2('addChat', "Add Chat"), - icon: Codicon.newSession, + icon: Codicon.plus, menu: [{ id: Menus.CommandCenter, order: 102, diff --git a/src/vs/sessions/contrib/sessions/common/sessionData.ts b/src/vs/sessions/contrib/sessions/common/sessionData.ts index c14004a0c1878..b2e164d51916a 100644 --- a/src/vs/sessions/contrib/sessions/common/sessionData.ts +++ b/src/vs/sessions/contrib/sessions/common/sessionData.ts @@ -67,11 +67,59 @@ export interface ISessionPullRequest { } /** - * A single chat as exposed by sessions providers. + * A single session as exposed by sessions providers. * Self-contained facade — components should not reach back to underlying * services to resolve additional data. */ -export interface IChatData { +export interface ISessionData { + /** Globally unique session ID (`providerId:localId`). */ + readonly id: string; + /** Resource URI identifying this session. */ + readonly resource: URI; + /** ID of the provider that owns this session. */ + readonly providerId: string; + /** Session type ID (e.g., 'copilot-cli', 'copilot-cloud'). */ + readonly sessionType: string; + /** Icon for this session. */ + readonly icon: ThemeIcon; + /** When the session was created. */ + readonly createdAt: Date; + /** Workspace this session operates on. */ + readonly workspace: IObservable; + + // Reactive properties + + /** Session display title (changes when auto-titled or renamed). */ + readonly title: IObservable; + /** When the session was last updated. */ + readonly updatedAt: IObservable; + /** Current session status. */ + readonly status: IObservable; + /** File changes produced by the session. */ + readonly changes: IObservable; + /** Currently selected model identifier. */ + readonly modelId: IObservable; + /** Currently selected mode identifier and kind. */ + readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined>; + /** Whether the session is still initializing (e.g., resolving git repository). */ + readonly loading: IObservable; + /** Whether the session is archived. */ + readonly isArchived: IObservable; + /** Whether the session has been read. */ + readonly isRead: IObservable; + /** Status description shown while the session is active (e.g., current agent action). */ + readonly description: IObservable; + /** Timestamp of when the last agent turn ended, if any. */ + readonly lastTurnEnd: IObservable; + /** Pull request associated with this session, if any. */ + readonly pullRequest: IObservable; +} + +/** + * A single chat within a session, produced by the sessions management layer. + * Has the same shape as {@link ISessionData} but uses `chatId` as its identifier. + */ +export interface IChat { /** Globally unique chat ID (`providerId:localId`). */ readonly chatId: string; /** Resource URI identifying this chat. */ @@ -117,9 +165,9 @@ export interface IChatData { /** * A session groups one or more chats together. - * All {@link IChatData} fields are propagated from the primary (first) chat. + * All {@link ISessionData} fields are propagated from the primary (first) chat. */ -export interface ISessionData { +export interface ISession { /** Globally unique session ID (`providerId:localId`). */ readonly sessionId: string; /** Resource URI identifying this session. */ @@ -162,9 +210,9 @@ export interface ISessionData { /** Pull request associated with this session, if any. */ readonly pullRequest: IObservable; /** The chats belonging to this session group. */ - readonly chats: IObservable; + readonly chats: IObservable; /** The currently active chat within this session group. */ - readonly activeChat: IObservable; + readonly activeChat: IObservable; /** The main chat within this session group (the first chat of the session). */ - readonly mainChat: IChatData; + readonly mainChat: IChat; } diff --git a/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts index 40f6cbce35656..9cf562073d878 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts @@ -24,7 +24,7 @@ import { ComponentFixtureContext, createEditorServices, defineComponentFixture, import { AICustomizationShortcutsWidget } from '../../browser/aiCustomizationShortcutsWidget.js'; import { CUSTOMIZATION_ITEMS, CustomizationLinkViewItem } from '../../browser/customizationsToolbar.contribution.js'; import { ISessionsManagementService } from '../../browser/sessionsManagementService.js'; -import { ISessionData } from '../../common/sessionData.js'; +import { ISession } from '../../common/sessionData.js'; import { Menus } from '../../../../browser/menus.js'; // Ensure color registrations are loaded @@ -201,7 +201,7 @@ function renderWidget(ctx: ComponentFixtureContext, options?: { mcpServerCount?: override readonly onDidChangeLanguageModels = Event.None; }()); reg.defineInstance(ISessionsManagementService, new class extends mock() { - override readonly activeSession = observableValue('activeSession', undefined); + override readonly activeSession = observableValue('activeSession', undefined); }()); reg.defineInstance(IFileService, new class extends mock() { override readonly onDidFilesChange = Event.None; diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index 473c3b272c762..f29dab46b0e58 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -8,7 +8,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { IChatData, ISessionData, SessionStatus } from '../../common/sessionData.js'; +import { IChat, ISession, SessionStatus } from '../../common/sessionData.js'; import { groupByWorkspace, sortSessions, SessionsSorting } from '../../browser/views/sessionsList.js'; function createSession(id: string, opts: { @@ -16,7 +16,7 @@ function createSession(id: string, opts: { createdAt?: Date; updatedAt?: Date; isArchived?: boolean; -}): ISessionData { +}): ISession { const createdAt = opts.createdAt ?? new Date(); const updatedAt = opts.updatedAt ?? createdAt; return { @@ -44,8 +44,8 @@ function createSession(id: string, opts: { description: observableValue(`description-${id}`, undefined), lastTurnEnd: observableValue(`lastTurnEnd-${id}`, undefined), pullRequest: observableValue(`pullRequest-${id}`, undefined), - chats: observableValue(`chats-${id}`, []), - activeChat: observableValue(`activeChat-${id}`, undefined!), + chats: observableValue(`chats-${id}`, []), + activeChat: observableValue(`activeChat-${id}`, undefined!), mainChat: undefined!, }; } diff --git a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts index d248d4c700535..645abc36af5fd 100644 --- a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts +++ b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts @@ -18,13 +18,14 @@ import { TerminalCapability } from '../../../../platform/terminal/common/capabil import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; import { Menus } from '../../../browser/menus.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; -import { ISessionData } from '../../sessions/common/sessionData.js'; +import { ISession } from '../../sessions/common/sessionData.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { TERMINAL_VIEW_ID } from '../../../../workbench/contrib/terminal/common/terminal.js'; import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { AGENT_HOST_SCHEME } from '../../../../platform/agentHost/common/agentHostUri.js'; const SessionsTerminalViewVisibleContext = new RawContextKey('sessionsTerminalViewVisible', false); @@ -33,12 +34,16 @@ const SessionsTerminalViewVisibleContext = new RawContextKey('sessionsT * background sessions only. Returns `undefined` for non-background sessions * (Cloud, Local, etc.) which have no local worktree, or when no path is available. */ -function getSessionCwd(session: ISessionData | undefined): URI | undefined { +function getSessionCwd(session: ISession | undefined): URI | undefined { if (session?.sessionType !== AgentSessionProviders.Background) { return undefined; } const repo = session.workspace.get()?.repositories[0]; - return repo?.workingDirectory ?? repo?.uri; + const cwd = repo?.workingDirectory ?? repo?.uri; + if (cwd?.scheme === AGENT_HOST_SCHEME) { + return undefined; + } + return cwd; } /** @@ -139,7 +144,7 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben return existing; } - private async _onActiveSessionChanged(session: ISessionData | undefined): Promise { + private async _onActiveSessionChanged(session: ISession | undefined): Promise { if (!session) { return; } diff --git a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts index 130ec222d04b2..95fb72dbf3871 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts @@ -14,9 +14,10 @@ import { TestInstantiationService } from '../../../../../platform/instantiation/ import { NullLogService, ILogService } from '../../../../../platform/log/common/log.js'; import { ITerminalInstance, ITerminalService } from '../../../../../workbench/contrib/terminal/browser/terminal.js'; import { ITerminalCapabilityStore, ICommandDetectionCapability, TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; +import { toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; import { AgentSessionProviders } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ISessionsChangeEvent, ISessionsManagementService } from '../../../sessions/browser/sessionsManagementService.js'; -import { IChatData, ISessionData } from '../../../sessions/common/sessionData.js'; +import { IChat, ISession } from '../../../sessions/common/sessionData.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { SessionsTerminalContribution } from '../../browser/sessionsTerminalContribution.js'; import { TestPathService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; @@ -46,7 +47,7 @@ function makeAgentSession(opts: { worktree?: URI; providerType?: string; isArchived?: boolean; -}): ISessionData { +}): ISession { const repo = opts.repository || opts.worktree ? { uri: opts.repository ?? opts.worktree!, workingDirectory: opts.worktree, @@ -54,7 +55,7 @@ function makeAgentSession(opts: { baseBranchName: undefined, baseBranchProtected: undefined, } : undefined; - const chat: IChatData = { + const chat: IChat = { chatId: 'test:session', resource: URI.parse('file:///session'), providerId: 'test', @@ -75,11 +76,11 @@ function makeAgentSession(opts: { description: observableValue('test.description', undefined), pullRequest: observableValue('test.pullRequest', undefined), }; - const session: ISessionData = { ...chat, sessionId: chat.chatId, chats: observableValue('test.chats', [chat]), activeChat: observableValue('test.activeChat', chat), mainChat: chat }; + const session: ISession = { ...chat, sessionId: chat.chatId, chats: observableValue('test.chats', [chat]), activeChat: observableValue('test.activeChat', chat), mainChat: chat }; return session; } -function makeNonAgentSession(opts: { repository?: URI; worktree?: URI; providerType?: string }): ISessionData { +function makeNonAgentSession(opts: { repository?: URI; worktree?: URI; providerType?: string }): ISession { const repo = opts.repository || opts.worktree ? { uri: opts.repository ?? opts.worktree!, workingDirectory: opts.worktree, @@ -87,7 +88,7 @@ function makeNonAgentSession(opts: { repository?: URI; worktree?: URI; providerT baseBranchName: undefined, baseBranchProtected: undefined, } : undefined; - const chat: IChatData = { + const chat: IChat = { chatId: 'test:non-agent', resource: URI.parse('file:///session'), providerId: 'test', @@ -108,7 +109,7 @@ function makeNonAgentSession(opts: { repository?: URI; worktree?: URI; providerT description: observableValue('test.description', undefined), pullRequest: observableValue('test.pullRequest', undefined), }; - const session: ISessionData = { ...chat, sessionId: chat.chatId, chats: observableValue('test.chats', [chat]), activeChat: observableValue('test.activeChat', chat), mainChat: chat }; + const session: ISession = { ...chat, sessionId: chat.chatId, chats: observableValue('test.chats', [chat]), activeChat: observableValue('test.activeChat', chat), mainChat: chat }; return session; } @@ -148,7 +149,7 @@ function addCommandToInstance(instance: ITerminalInstance, timestamp: number): v suite('SessionsTerminalContribution', () => { const store = new DisposableStore(); let contribution: SessionsTerminalContribution; - let activeSessionObs: ReturnType>; + let activeSessionObs: ReturnType>; let onDidChangeSessions: Emitter; let onDidCreateInstance: Emitter; @@ -179,7 +180,7 @@ suite('SessionsTerminalContribution', () => { const instantiationService = store.add(new TestInstantiationService()); - activeSessionObs = observableValue('activeSession', undefined); + activeSessionObs = observableValue('activeSession', undefined); onDidChangeSessions = store.add(new Emitter()); onDidCreateInstance = store.add(new Emitter()); @@ -665,6 +666,18 @@ suite('SessionsTerminalContribution', () => { // No setActiveInstance calls from visibility update since no commands were run assert.strictEqual(activeInstanceSet.length, activeCountBefore, 'should not call setActiveInstance when no command history exists'); }); + + // --- Remote agent host sessions --- + + test('falls back to home directory for a background session with a remote agent host repository', async () => { + const remoteRepoUri = toAgentHostUri(URI.file('/Users/user/repo'), 'my-server'); + const session = makeAgentSession({ repository: remoteRepoUri, providerType: AgentSessionProviders.Background }); + activeSessionObs.set(session, undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 1, 'should create a terminal at the home directory'); + assert.strictEqual(createdTerminals[0].cwd.fsPath, HOME_DIR.fsPath); + }); }); function tick(): Promise { diff --git a/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts index 9f1083cef728d..3c47c1c2c4f4f 100644 --- a/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts +++ b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts @@ -17,7 +17,7 @@ import { IWorkspaceFolderCreationData } from '../../../../platform/workspaces/co import { getGitHubRemoteFileDisplayName } from '../../fileTreeView/browser/githubFileSystemProvider.js'; import { Queue } from '../../../../base/common/async.js'; import { AGENT_HOST_SCHEME } from '../../../../platform/agentHost/common/agentHostUri.js'; -import { ISessionData } from '../../sessions/common/sessionData.js'; +import { ISession } from '../../sessions/common/sessionData.js'; export class WorkspaceFolderManagementContribution extends Disposable implements IWorkbenchContribution { @@ -39,7 +39,7 @@ export class WorkspaceFolderManagementContribution extends Disposable implements })); } - private async updateWorkspaceFoldersForSession(session: ISessionData | undefined): Promise { + private async updateWorkspaceFoldersForSession(session: ISession | undefined): Promise { await this.manageTrustWorkspaceForSession(session); const activeSessionFolderData = this.getActiveSessionFolderData(session); const currentRepo = this.workspaceContextService.getWorkspace().folders[0]?.uri; @@ -63,7 +63,7 @@ export class WorkspaceFolderManagementContribution extends Disposable implements await this.workspaceEditingService.updateFolders(0, 1, [activeSessionFolderData], true); } - private getActiveSessionFolderData(session: ISessionData | undefined): IWorkspaceFolderCreationData | undefined { + private getActiveSessionFolderData(session: ISession | undefined): IWorkspaceFolderCreationData | undefined { if (!session) { return undefined; } @@ -102,7 +102,7 @@ export class WorkspaceFolderManagementContribution extends Disposable implements return undefined; } - private async manageTrustWorkspaceForSession(session: ISessionData | undefined): Promise { + private async manageTrustWorkspaceForSession(session: ISession | undefined): Promise { if (session?.sessionType !== AgentSessionProviders.Background) { return; } diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index ad86d3eb21244..cda49bdc28cbb 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -146,7 +146,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA })); this._register(this._chatService.onDidDisposeSession(e => { - for (const resource of e.sessionResource) { + for (const resource of e.sessionResources) { this._proxy.$releaseSession(resource); } })); @@ -632,6 +632,9 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA type: item.type, name: item.name, description: item.description, + groupKey: item.groupKey, + badge: item.badge, + badgeTooltip: item.badgeTooltip, })); }, }; @@ -653,7 +656,6 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA label: metadata.label, icon: metadata.iconId ? ThemeIcon.fromId(metadata.iconId) : ThemeIcon.fromId(Codicon.extensions.id), hiddenSections, - workspaceSubpaths: metadata.workspaceSubpaths ? [...metadata.workspaceSubpaths] : undefined, getStorageSourceFilter: () => ({ // Extension-provided harnesses manage their own items via the provider, // so we show all sources for storage-filter-based flows. diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 8f4f9f234e4a5..928fa28198e18 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -6,12 +6,14 @@ import { raceCancellationError } from '../../../base/common/async.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { Emitter } from '../../../base/common/event.js'; -import { IMarkdownString, MarkdownString } from '../../../base/common/htmlContent.js'; -import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { IMarkdownString, MarkdownString, markdownStringEqual } from '../../../base/common/htmlContent.js'; +import { Disposable, DisposableMap, DisposableResourceMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../base/common/map.js'; import { revive } from '../../../base/common/marshalling.js'; -import { autorun, IObservable, observableValue } from '../../../base/common/observable.js'; +import { equals } from '../../../base/common/objects.js'; +import { autorun, IObservable, observableSignalFromEvent, observableValue } from '../../../base/common/observable.js'; import { isEqual } from '../../../base/common/resources.js'; +import { ThemeIcon } from '../../../base/common/themables.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { IDialogService } from '../../../platform/dialogs/common/dialogs.js'; @@ -20,18 +22,18 @@ import { ILogService } from '../../../platform/log/common/log.js'; import { hasValidDiff, IAgentSession } from '../../contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../../contrib/chat/browser/agentSessions/agentSessionsService.js'; import { IChatWidgetService, isIChatViewViewContext } from '../../contrib/chat/browser/chat.js'; +import { getInProgressSessionDescription } from '../../contrib/chat/browser/chatSessions/chatSessionDescription.js'; +import { getSessionStatusForModel } from '../../contrib/chat/browser/chatSessions/chatSessions.contribution.js'; import { IChatEditorOptions } from '../../contrib/chat/browser/widgetHosts/editor/chatEditor.js'; import { ChatEditorInput } from '../../contrib/chat/browser/widgetHosts/editor/chatEditorInput.js'; import { IChatRequestVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; -import { awaitStatsForSession } from '../../contrib/chat/common/chat.js'; -import { getInProgressSessionDescription } from '../../contrib/chat/browser/chatSessions/chatSessionDescription.js'; -import { IChatContentInlineReference, IChatProgress, IChatService, ResponseModelState } from '../../contrib/chat/common/chatService/chatService.js'; +import { IChatDebugService } from '../../contrib/chat/common/chatDebugService.js'; +import { IChatContentInlineReference, IChatDetail, IChatProgress, IChatService, IChatSessionTiming } from '../../contrib/chat/common/chatService/chatService.js'; import { ChatSessionOptionsMap, ChatSessionStatus, IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsService, ReadonlyChatSessionOptionsMap } from '../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { IChatModel } from '../../contrib/chat/common/model/chatModel.js'; -import { isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js'; +import { getChatSessionType, isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js'; import { IChatAgentRequest } from '../../contrib/chat/common/participants/chatAgents.js'; -import { IChatDebugService } from '../../contrib/chat/common/chatDebugService.js'; import { IChatArtifactsService } from '../../contrib/chat/common/tools/chatArtifactsService.js'; import { IChatTodoListService } from '../../contrib/chat/common/tools/chatTodoListService.js'; import { IEditorGroupsService } from '../../services/editor/common/editorGroupsService.js'; @@ -40,6 +42,19 @@ import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; import { ChatSessionContentContextDto, ExtHostChatSessionsShape, ExtHostContext, IChatProgressDto, IChatSessionHistoryItemDto, IChatSessionItemsChange, IChatSessionRequestHistoryItemDto, MainContext, MainThreadChatSessionsShape } from '../common/extHost.protocol.js'; +function stringOrMarkdownEqual(a: string | IMarkdownString | undefined, b: string | IMarkdownString | undefined): boolean { + if (a === b) { + return true; + } + if (!a || !b) { + return false; + } + if (typeof a === 'string' || typeof b === 'string') { + return false; + } + return markdownStringEqual(a, b); +} + export class ObservableChatSession extends Disposable implements IChatSession { readonly sessionResource: URI; @@ -358,26 +373,65 @@ class MainThreadChatSessionItemController extends Disposable implements IChatSes private readonly _onDidChangeChatSessionItems = this._register(new Emitter()); public readonly onDidChangeChatSessionItems = this._onDidChangeChatSessionItems.event; + private readonly _modelListeners = this._register(new DisposableResourceMap()); + + private _isDisposed = false; + constructor( proxy: ExtHostChatSessionsShape, chatSessionType: string, handle: number, - @IChatService chatService: IChatService, + @IChatService private readonly _chatService: IChatService, @ILogService private readonly _logService: ILogService, ) { super(); + this._proxy = proxy; this._handle = handle; - this._register(chatService.registerChatModelChangeListeners(chatSessionType, (sessionResource) => { - const item = this._items.get(sessionResource); - if (item) { - this._onDidChangeChatSessionItems.fire({ addedOrUpdated: [item] }); + // Update the chat session item based on on the actual model state + // TODO: This should be based on the chat session content provider instead of the chat models directly + // or bed moved into the chat session service so that all controllers get the same behavior. + const addModelListeners = async (model: IChatModel) => { + if (getChatSessionType(model.sessionResource) !== chatSessionType) { + return; + } + + await this.refresh(CancellationToken.None); + if (this._isDisposed) { + return; + } + + this.tryUpdateItemForModel(model); + + const requestChangeListener = model.lastRequestObs.map(last => last?.response && observableSignalFromEvent('chatSessions.modelRequestChangeListener', last.response.onDidChange)); + const modelChangeListener = observableSignalFromEvent('chatSessions.modelChangeListener', model.onDidChange); + this._modelListeners.set(model.sessionResource, autorun(reader => { + requestChangeListener.read(reader)?.read(reader); + modelChangeListener.read(reader); + + this.tryUpdateItemForModel(model); + })); + }; + + this._register(_chatService.onDidCreateModel(model => addModelListeners(model))); + for (const model of _chatService.chatModels.get()) { + addModelListeners(model); + } + + this._register(_chatService.onDidDisposeSession(e => { + for (const sessionResource of e.sessionResources) { + this._modelListeners.deleteAndDispose(sessionResource); } })); } - private readonly _items = new ResourceMap(); + override dispose(): void { + this._isDisposed = true; + super.dispose(); + } + + private readonly _items = new ResourceMap(); get items(): IChatSessionItem[] { return Array.from(this._items.values()); } @@ -395,38 +449,106 @@ class MainThreadChatSessionItemController extends Disposable implements IChatSes if (!dto) { return undefined; } - const item: IChatSessionItem = { - ...dto, - resource: URI.revive(dto.resource), - changes: revive(dto.changes), - }; - this._items.set(item.resource, item); - this._onDidChangeChatSessionItems.fire({ - addedOrUpdated: [item], - }); + const item = this.addOrUpdateItem(dto); return item; } - acceptChange(change: { readonly addedOrUpdated: readonly IChatSessionItem[]; readonly removed: readonly URI[] }): void { + async acceptChange(change: { readonly addedOrUpdated: readonly Dto[]; readonly removed: readonly URI[] }): Promise { + const addedOrUpdatedItems: MainThreadChatSessionItem[] = []; for (const item of change.addedOrUpdated) { - warnOnUntitledSessionResource(item.resource, this._logService); - this._items.set(item.resource, item); + addedOrUpdatedItems.push(await this.addOrUpdateItem(item)); } for (const uri of change.removed) { this._items.delete(uri); } this._onDidChangeChatSessionItems.fire({ - addedOrUpdated: change.addedOrUpdated, + addedOrUpdated: addedOrUpdatedItems, removed: change.removed, }); } - addOrUpdateItem(item: IChatSessionItem): void { - warnOnUntitledSessionResource(item.resource, this._logService); - this._items.set(item.resource, item); + private async addOrUpdateItem(dto: Dto): Promise { + const resource = URI.revive(dto.resource); + warnOnUntitledSessionResource(resource, this._logService); + + const existing = this._items.get(resource); + const updated = new MainThreadChatSessionItem(dto, this._chatService.getSession(resource), await this._chatService.getMetadataForSession(resource)); + if (existing?.isEqual(updated)) { + return existing; + } + + this._items.set(resource, updated); this._onDidChangeChatSessionItems.fire({ - addedOrUpdated: [item], + addedOrUpdated: [updated], }); + return updated; + } + + private async tryUpdateItemForModel(model: IChatModel): Promise { + const resource = model.sessionResource; + const existing = this._items.get(resource); + if (existing) { + this.addOrUpdateItem(existing); + } + } +} + +class MainThreadChatSessionItem implements IChatSessionItem { + readonly resource: URI; + + readonly label: string; + readonly iconPath?: ThemeIcon; + readonly badge?: string | IMarkdownString; + readonly description?: string | IMarkdownString; + readonly status?: ChatSessionStatus; + readonly tooltip?: string | IMarkdownString; + readonly timing: IChatSessionTiming; + readonly changes?: IChatSessionItem['changes']; + readonly archived?: boolean; + readonly metadata?: { readonly [key: string]: unknown }; + + constructor(dto: Dto, model: IChatModel | undefined, detailOverrides: IChatDetail | undefined) { + this.resource = URI.revive(dto.resource); + this.label = dto.label; + this.timing = dto.timing; + this.iconPath = dto.iconPath; + this.badge = reviveMarkdownString(dto.badge); + this.tooltip = reviveMarkdownString(dto.tooltip); + this.archived = dto.archived; + this.metadata = dto.metadata; + + this.description = (model && getInProgressSessionDescription(model)) ?? reviveMarkdownString(dto.description); + this.status = (model && getSessionStatusForModel(model)) ?? dto.status; + + this.changes = revive(dto.changes); + + // We can still get stats if there is no model or if fetching from model failed + if (detailOverrides && !this.changes) { + const diffs: IAgentSession['changes'] = { + files: detailOverrides.stats?.fileCount || 0, + insertions: detailOverrides.stats?.added || 0, + deletions: detailOverrides.stats?.removed || 0 + }; + if (hasValidDiff(diffs)) { + this.changes = diffs; + } + } + } + + isEqual(other: MainThreadChatSessionItem): boolean { + return isEqual(this.resource, other.resource) + && this.label === other.label + && this.description === other.description + && this.status === other.status + && this.timing.created === other.timing.created + && this.timing.lastRequestStarted === other.timing.lastRequestStarted + && this.timing.lastRequestEnded === other.timing.lastRequestEnded + && equals(this.changes, other.changes) + && equals(this.iconPath, other.iconPath) + && stringOrMarkdownEqual(this.badge, other.badge) + && stringOrMarkdownEqual(this.tooltip, other.tooltip) + && this.archived === other.archived + && equals(this.metadata, other.metadata); } } @@ -510,50 +632,20 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat return registration.controller; } - private async _resolveSessionItem(item: Dto): Promise { - const uri = URI.revive(item.resource); - const model = this._chatService.getSession(uri); - if (model) { - item = await this.handleSessionModelOverrides(model, item); - } - - // We can still get stats if there is no model or if fetching from model failed - let changes = revive(item.changes); - if (!changes || !model) { - const stats = (await this._chatService.getMetadataForSession(uri))?.stats; - const diffs: IAgentSession['changes'] = { - files: stats?.fileCount || 0, - insertions: stats?.added || 0, - deletions: stats?.removed || 0 - }; - if (hasValidDiff(diffs)) { - changes = diffs; - } - } - - return { - ...item, - changes, - resource: uri, - iconPath: item.iconPath, - tooltip: item.tooltip ? this._reviveTooltip(item.tooltip) : undefined, - archived: item.archived, - }; - } - async $updateChatSessionItems(controllerHandle: number, change: IChatSessionItemsChange): Promise { const controller = this.getController(controllerHandle); - const resolvedItems = await Promise.all(change.addedOrUpdated.map(item => this._resolveSessionItem(item))); controller.acceptChange({ - addedOrUpdated: resolvedItems, + addedOrUpdated: change.addedOrUpdated, removed: change.removed.map(uri => URI.revive(uri)) }); } async $addOrUpdateChatSessionItem(controllerHandle: number, item: Dto): Promise { const controller = this.getController(controllerHandle); - const resolvedItem = await this._resolveSessionItem(item); - controller.addOrUpdateItem(resolvedItem); + controller.acceptChange({ + addedOrUpdated: [item], + removed: [] + }); } $onDidChangeChatSessionOptions(handle: number, sessionResourceComponents: UriComponents, updates: Record): void { @@ -665,36 +757,6 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat this._chatService.migrateRequests(originalResource, modifiedResource); } - private async handleSessionModelOverrides(model: IChatModel, session: Dto): Promise> { - const outgoingSession = { ...session }; - - // Override description if there's an in-progress count - const inProgress = model.getRequests().filter(r => r.response && !r.response.isComplete); - if (inProgress.length) { - outgoingSession.description = getInProgressSessionDescription(model); - } - - // Override changes - // TODO: @osortega we don't really use statistics anymore, we need to clarify that in the API - if (!(outgoingSession.changes instanceof Array)) { - const modelStats = await awaitStatsForSession(model); - if (modelStats) { - outgoingSession.changes = { - files: modelStats.fileCount, - insertions: modelStats.added, - deletions: modelStats.removed - }; - } - } - - // Override status if the models needs input - if (model.lastRequest?.response?.state === ResponseModelState.NeedsInput) { - outgoingSession.status = ChatSessionStatus.NeedsInput; - } - - return outgoingSession; - } - private async _provideChatSessionContent(providerHandle: number, sessionResource: URI, token: CancellationToken): Promise { warnOnUntitledSessionResource(sessionResource, this._logService); @@ -853,23 +915,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat super.dispose(); } - private _reviveTooltip(tooltip: string | IMarkdownString | undefined): string | MarkdownString | undefined { - if (!tooltip) { - return undefined; - } - - // If it's already a string, return as-is - if (typeof tooltip === 'string') { - return tooltip; - } - // If it's a serialized IMarkdownString, revive it to MarkdownString - if (typeof tooltip === 'object' && 'value' in tooltip) { - return MarkdownString.lift(tooltip); - } - - return undefined; - } /** * Notify the extension about option changes for a session @@ -890,3 +936,21 @@ function warnOnUntitledSessionResource(resource: URI, logService: ILogService): logService.warn(`[MainThreadChatSessions] untitled-style sessionResource detected ${resource.toString()}`); } } + +function reviveMarkdownString(value: string | IMarkdownString | undefined): string | MarkdownString | undefined { + if (!value) { + return undefined; + } + + // If it's already a string, return as-is + if (typeof value === 'string') { + return value; + } + + // If it's a serialized IMarkdownString, revive it to MarkdownString + if (typeof value === 'object' && 'value' in value) { + return MarkdownString.lift(value); + } + + return undefined; +} diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 97241cabb6766..83b68a5ec4c83 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1682,7 +1682,6 @@ export interface IChatSessionCustomizationProviderMetadataDto { readonly label: string; readonly iconId?: string; readonly unsupportedTypes?: readonly string[]; - readonly workspaceSubpaths?: readonly string[]; } export interface IChatSessionCustomizationItemDto { @@ -1690,6 +1689,9 @@ export interface IChatSessionCustomizationItemDto { readonly type: string; readonly name: string; readonly description?: string; + readonly groupKey?: string; + readonly badge?: string; + readonly badgeTooltip?: string; } export interface IChatParticipantMetadata { participant: string; diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 9e7ac0fb7d3af..f6d282ea53cf8 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -672,7 +672,6 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS label: metadata.label, iconId: metadata.iconId, unsupportedTypes: metadata.unsupportedTypes?.map(t => typeConvert.ChatSessionCustomizationType.from(t)), - workspaceSubpaths: metadata.workspaceSubpaths ? [...metadata.workspaceSubpaths] : undefined, }; this._proxy.$registerChatSessionCustomizationProvider(handle, chatSessionType, metadataDto, extension.identifier); @@ -710,6 +709,9 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS type: typeConvert.ChatSessionCustomizationType.from(item.type), name: item.name, description: item.description, + groupKey: item.groupKey, + badge: item.badge, + badgeTooltip: item.badgeTooltip, })); } catch (err) { return undefined; diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index 8f9cfa3f96b69..5508cfc68ebab 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -7,11 +7,13 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; import { CDPEvent, CDPRequest, CDPResponse } from '../../../../platform/browserView/common/cdp/types.js'; -import { IPlaywrightService } from '../../../../platform/browserView/common/playwrightService.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { localize } from '../../../../nls.js'; +import { IElementData } from '../../../../platform/browserElements/common/browserElements.js'; +import { IPlaywrightService } from '../../../../platform/browserView/common/playwrightService.js'; import { IBrowserViewBounds, IBrowserViewNavigationEvent, @@ -209,6 +211,9 @@ export interface IBrowserViewModel extends IDisposable { zoomIn(): Promise; zoomOut(): Promise; resetZoom(): Promise; + getConsoleLogs(): Promise; + getElementData(token: CancellationToken): Promise; + getFocusedElementData(): Promise; } export class BrowserViewModel extends Disposable implements IBrowserViewModel { @@ -464,7 +469,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { async captureScreenshot(options?: IBrowserViewCaptureScreenshotOptions): Promise { const result = await this.browserViewService.captureScreenshot(this.id, options); // Store full-page screenshots for display in UI as placeholders - if (!options?.rect) { + if (!options?.screenRect && !options?.pageRect) { this._screenshot = result; } return result; @@ -541,6 +546,18 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { } } + async getConsoleLogs(): Promise { + return this.browserViewService.getConsoleLogs(this.id); + } + + async getElementData(token: CancellationToken): Promise { + return this._wrapCancellable(token, (cid) => this.browserViewService.getElementData(this.id, cid)); + } + + async getFocusedElementData(): Promise { + return this.browserViewService.getFocusedElementData(this.id); + } + private static readonly SHARE_DONT_ASK_KEY = 'browserView.shareWithAgent.dontAskAgain'; async setSharedWithAgent(shared: boolean): Promise { @@ -601,6 +618,19 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { } } + private static _cancellationIdPool = 0; + private async _wrapCancellable(token: CancellationToken, callback: (cancellationId: number) => Promise): Promise { + const cancellationId = BrowserViewModel._cancellationIdPool++; + const disposable = token.onCancellationRequested(() => { + this.browserViewService.cancel(cancellationId); + }); + try { + return await callback(cancellationId); + } finally { + disposable.dispose(); + } + } + /** * Log navigation telemetry event */ diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts index b44e966f2db58..08eba39df4c29 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts @@ -14,17 +14,16 @@ import { KeyMod, KeyCode } from '../../../../../base/common/keyCodes.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { Event } from '../../../../../base/common/event.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; -import { IBrowserElementsService } from '../../../../services/browserElements/browser/browserElementsService.js'; import { IChatWidgetService } from '../../../chat/browser/chat.js'; import { IChatRequestVariableEntry } from '../../../chat/common/attachments/chatVariableEntries.js'; import { ChatContextKeys } from '../../../chat/common/actions/chatContextKeys.js'; import { ChatConfiguration } from '../../../chat/common/constants.js'; -import { IElementData, IBrowserTargetLocator, getDisplayNameFromOuterHTML, createElementContextValue } from '../../../../../platform/browserElements/common/browserElements.js'; +import { IElementData, createElementContextValue } from '../../../../../platform/browserElements/common/browserElements.js'; import { BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; import { IBrowserViewModel } from '../../../browserView/common/browserView.js'; import { BrowserEditorInput } from '../../common/browserEditorInput.js'; @@ -36,10 +35,11 @@ import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '. import { Registry } from '../../../../../platform/registry/common/platform.js'; import { PolicyCategory } from '../../../../../base/common/policy.js'; import { workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; +import { safeSetInnerHtml } from '../../../../../base/browser/domSanitize.js'; +import { BrowserActionCategory } from '../browserViewActions.js'; // Register tools import '../tools/browserTools.contribution.js'; -import { BrowserActionCategory } from '../browserViewActions.js'; // Context key expression to check if browser editor is active const BROWSER_EDITOR_ACTIVE = ContextKeyExpr.equals('activeEditor', BrowserEditorInput.EDITOR_ID); @@ -56,7 +56,7 @@ const canShareBrowserWithAgentContext = ContextKeyExpr.and( /** * Contribution that manages element selection, element attachment to chat, - * console session lifecycle, console log attachment to chat, and agent sharing. + * console log attachment to chat, and agent sharing. */ export class BrowserEditorChatIntegration extends BrowserEditorContribution { private _elementSelectionCts: CancellationTokenSource | undefined; @@ -72,7 +72,6 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { @IInstantiationService instantiationService: IInstantiationService, @ITelemetryService private readonly telemetryService: ITelemetryService, @ILogService private readonly logService: ILogService, - @IBrowserElementsService private readonly browserElementsService: IBrowserElementsService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @IConfigurationService private readonly configurationService: IConfigurationService, ) { @@ -117,15 +116,6 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { } protected override subscribeToModel(model: IBrowserViewModel, store: DisposableStore): void { - // Start console session when a page URL is loaded - if (model.url) { - store.add(this._startConsoleSession(model.id)); - } else { - store.add(Event.once(Event.filter(model.onDidNavigate, e => !!e.url))(() => { - store.add(this._startConsoleSession(model.id)); - })); - } - // Manage sharing state this._updateSharingState(true); store.add(model.onDidChangeSharedWithAgent(() => { @@ -200,24 +190,16 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { this.telemetryService.publicLog2('integratedBrowser.addElementToChat.start', {}); try { - const browserViewId = this.editor.model?.id; - if (!browserViewId) { - throw new Error('No browser view ID found'); + const model = this.editor.model; + if (!model) { + throw new Error('No browser view model found'); } // Make the browser the focused view this.editor.ensureBrowserFocus(); - const locator: IBrowserTargetLocator = { browserViewId }; - - // Start debug session for integrated browser - await this.browserElementsService.startDebugSession(cts.token, locator); - - // Get the browser container bounds - const { width, height } = this.editor.browserContainer.getBoundingClientRect(); - // Get element data from user selection - const elementData = await this.browserElementsService.getElementData({ x: 0, y: 0, width, height }, cts.token, locator); + const elementData = await model.getElementData(cts.token); if (!elementData) { throw new Error('Element data not found'); } @@ -246,7 +228,7 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { this.logService.error('BrowserEditor.addElementToChat: Failed to select element', error); } } finally { - cts.dispose(); + cts.dispose(true); if (this._elementSelectionCts === cts) { this._elementSelectionCts = undefined; this._elementSelectionActiveContext.set(false); @@ -263,20 +245,18 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { } const cts = this._elementSelectionCts; - const browserViewId = this.editor.model?.id; - if (!browserViewId) { + const model = this.editor.model; + if (!model) { return; } - const locator: IBrowserTargetLocator = { browserViewId }; - const { width, height } = this.editor.browserContainer.getBoundingClientRect(); - const elementData = await this.browserElementsService.getFocusedElementData({ x: 0, y: 0, width, height }, cts.token, locator); + const elementData = await model.getFocusedElementData(); if (!elementData) { return; } await this._attachElementDataToChat(elementData); - cts.dispose(); + cts.dispose(true); if (this._elementSelectionCts === cts) { this._elementSelectionCts = undefined; this._elementSelectionActiveContext.set(false); @@ -287,14 +267,31 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { const bounds = elementData.bounds; const toAttach: IChatRequestVariableEntry[] = []; - const displayName = getDisplayNameFromOuterHTML(elementData.outerHTML); + const container = document.createElement('div'); + safeSetInnerHtml(container, elementData.outerHTML); + const element = container.firstElementChild; + const innerText = container.textContent; + + let displayNameShort = element ? `${element.tagName.toLowerCase()}${element.id ? `#${element.id}` : ''}` : ''; + let displayNameFull = element ? `${displayNameShort}${element.classList.length ? `.${[...element.classList].join('.')}` : ''}` : ''; + if (elementData.ancestors && elementData.ancestors.length > 0) { + let last = elementData.ancestors[elementData.ancestors.length - 1]; + let pseudo = ''; + if (last.tagName.startsWith('::') && elementData.ancestors.length > 1) { + pseudo = last.tagName; + last = elementData.ancestors[elementData.ancestors.length - 2]; + } + displayNameShort = `${last.tagName.toLowerCase()}${last.id ? `#${last.id}` : ''}${pseudo}`; + displayNameFull = `${last.tagName.toLowerCase()}${last.id ? `#${last.id}` : ''}${last.classNames && last.classNames.length ? `.${last.classNames.join('.')}` : ''}${pseudo}`; + } + const attachCss = this.configurationService.getValue('chat.sendElementsToChat.attachCSS'); - const value = createElementContextValue(elementData, displayName, attachCss); + const value = createElementContextValue(elementData, displayNameFull, attachCss); toAttach.push({ id: 'element-' + Date.now(), - name: displayName, - fullName: displayName, + name: displayNameShort, + fullName: displayNameFull, value: value, modelDescription: attachCss ? 'Structured browser element context with HTML path, attributes, and computed styles.' @@ -305,7 +302,7 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { attributes: elementData.attributes, computedStyles: attachCss ? elementData.computedStyles : undefined, dimensions: elementData.dimensions, - innerText: elementData.innerText, + innerText, }); const attachImages = this.configurationService.getValue('chat.sendElementsToChat.attachImages'); @@ -313,7 +310,7 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { if (attachImages && model) { const screenshotBuffer = await model.captureScreenshot({ quality: 90, - rect: bounds + pageRect: bounds }); toAttach.push({ @@ -337,15 +334,13 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { * Grab the current console logs from the active console session and attach them to chat. */ async addConsoleLogsToChat(): Promise { - const browserViewId = this.editor.model?.id; - if (!browserViewId) { + const model = this.editor.model; + if (!model) { return; } - const locator: IBrowserTargetLocator = { browserViewId }; - try { - const logs = await this.browserElementsService.getConsoleLogs(locator); + const logs = await model.getConsoleLogs(); if (!logs) { return; } @@ -367,21 +362,6 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { this.logService.error('BrowserEditor.addConsoleLogsToChat: Failed to get console logs', error); } } - - private _startConsoleSession(browserViewId: string): IDisposable { - const cts = new CancellationTokenSource(); - const locator: IBrowserTargetLocator = { browserViewId }; - - this.browserElementsService.startConsoleSession(cts.token, locator).catch(error => { - if (!cts.token.isCancellationRequested) { - this.logService.error('BrowserEditor: Failed to start console session', error); - } - }); - - return toDisposable(() => { - cts.dispose(true); - }); - } } // Register the contribution diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts index b2bb1f9a3fed7..d663b66cc30ec 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts @@ -87,14 +87,7 @@ export class ScreenshotBrowserTool implements IToolImpl { } return locator.boundingBox(); }, selector, params.scrollIntoViewIfNeeded) || undefined; - const zoomFactor = browserViewModel.zoomFactor; - if (bounds) { - bounds.x *= zoomFactor; - bounds.y *= zoomFactor; - bounds.width *= zoomFactor; - bounds.height *= zoomFactor; - } - const screenshot = await browserViewModel.captureScreenshot({ rect: bounds }); + const screenshot = await browserViewModel.captureScreenshot({ pageRect: bounds }); return { content: [ diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts index d6522e25c2a6d..156b847d16f00 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts @@ -117,7 +117,7 @@ export function registerChatOpenAgentDebugPanelAction() { super({ id: 'workbench.action.chat.exportAgentDebugLog', title: localize2('chat.exportAgentDebugLog.label', "Export Agent Debug Log..."), - icon: Codicon.desktopDownload, + icon: Codicon.chatExport, f1: true, category: Categories.Developer, precondition: ChatContextKeys.enabled, @@ -182,7 +182,7 @@ export function registerChatOpenAgentDebugPanelAction() { super({ id: 'workbench.action.chat.importAgentDebugLog', title: localize2('chat.importAgentDebugLog.label', "Import Agent Debug Log..."), - icon: Codicon.cloudUpload, + icon: Codicon.chatImport, f1: true, category: Categories.Developer, precondition: ChatContextKeys.enabled, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index d1eacaa63baac..b7bf76c94fc34 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { IMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { generateUuid } from '../../../../../../base/common/uuid.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ToolCallStatus, TurnState, ResponsePartKind, getToolFileEdits, getToolOutputText, type IActiveTurn, type ICompletedToolCall, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { getToolKind, getToolLanguage } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; @@ -46,12 +45,20 @@ export function turnsToHistory(turns: readonly ITurn[], participantId: string): switch (rp.kind) { case ResponsePartKind.Markdown: if (rp.content) { - parts.push({ kind: 'markdownContent', content: new MarkdownString(rp.content) }); + parts.push({ kind: 'markdownContent', content: new MarkdownString(rp.content, { supportHtml: true }) }); } break; - case ResponsePartKind.ToolCall: - parts.push(completedToolCallToSerialized(rp.toolCall as ICompletedToolCall)); + case ResponsePartKind.ToolCall: { + const tc = rp.toolCall as ICompletedToolCall; + const fileEditParts = completedToolCallToEditParts(tc); + const serialized = completedToolCallToSerialized(tc); + if (fileEditParts.length > 0) { + serialized.presentation = ToolInvocationPresentation.Hidden; + } + parts.push(serialized); + parts.push(...fileEditParts); break; + } case ResponsePartKind.Reasoning: if (rp.content) { parts.push({ kind: 'thinking', value: rp.content }); @@ -163,6 +170,35 @@ function completedToolCallToSerialized(tc: ICompletedToolCall): IChatToolInvocat }; } +/** + * Builds edit-structure progress parts for a completed tool call that + * produced file edits. Returns an empty array if the tool call has no edits. + * These parts replay the undo stops and code-block UI when restoring history. + */ +function completedToolCallToEditParts(tc: ICompletedToolCall): IChatProgress[] { + if (tc.status !== ToolCallStatus.Completed) { + return []; + } + const fileEdits = getToolFileEdits(tc); + if (fileEdits.length === 0) { + return []; + } + const filePath = getFilePathFromToolInput(tc); + if (!filePath) { + return []; + } + const fileUri = URI.file(filePath); + const parts: IChatProgress[] = []; + for (const _edit of fileEdits) { + parts.push({ kind: 'markdownContent', content: new MarkdownString('\n````\n') }); + parts.push({ kind: 'codeblockUri', uri: fileUri, isEdit: true, undoStopId: tc.toolCallId }); + parts.push({ kind: 'textEdit', uri: fileUri, edits: [], done: false, isExternalEdit: true }); + parts.push({ kind: 'textEdit', uri: fileUri, edits: [], done: true, isExternalEdit: true }); + parts.push({ kind: 'markdownContent', content: new MarkdownString('\n````\n') }); + } + return parts; +} + /** * Creates a live {@link ChatToolInvocation} from the protocol's tool-call * state. Used during active turns to represent running tool calls in the UI. @@ -319,7 +355,7 @@ function fileEditsToExternalEdits(tc: IToolCallState): IToolCallFileEdit[] { resource: URI.file(filePath), beforeContentUri: URI.parse(edit.beforeURI), afterContentUri: URI.parse(edit.afterURI), - undoStopId: generateUuid(), + undoStopId: tc.toolCallId, }); } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts index 0ec54a9d1653a..5820c25f6b990 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts @@ -234,6 +234,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { || e.affectsConfiguration(ChatConfiguration.ChatViewSessionsEnabled) || e.affectsConfiguration(ChatConfiguration.AIDisabled) || e.affectsConfiguration(ChatConfiguration.ChatCustomizationMenuEnabled) + || e.affectsConfiguration(ChatConfiguration.SignInTitleBarEnabled) || e.affectsConfiguration('disableAICustomizations') || e.affectsConfiguration('workbench.disableAICustomizations') ) { @@ -870,8 +871,9 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { let primaryActionTitle = localize('toggleChat', "Toggle Chat"); let primaryActionIcon = Codicon.chatSparkle; + const signInTitleBarEnabled = this.configurationService.getValue(ChatConfiguration.SignInTitleBarEnabled); if (chatSentiment.installed && !chatSentiment.disabled) { - if (signedOut && !anonymous) { + if (signedOut && !anonymous && !signInTitleBarEnabled) { primaryActionId = CHAT_SETUP_ACTION_ID; primaryActionTitle = localize('signInToChatSetup', "Sign in to use AI features..."); primaryActionIcon = Codicon.chatSparkleError; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts index a077f22635f86..8389624d685f7 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts @@ -7,18 +7,20 @@ import { coalesce } from '../../../../../base/common/arrays.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; -import { Disposable } from '../../../../../base/common/lifecycle.js'; -import { ResourceSet } from '../../../../../base/common/map.js'; -import { Schemas } from '../../../../../base/common/network.js'; +import { Disposable, DisposableResourceMap } from '../../../../../base/common/lifecycle.js'; +import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; +import { equals } from '../../../../../base/common/objects.js'; +import { autorun, observableSignalFromEvent } from '../../../../../base/common/observable.js'; import { isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; -import { convertLegacyChatSessionTiming, IChatDetail, IChatService, ResponseModelState } from '../../common/chatService/chatService.js'; +import { convertLegacyChatSessionTiming, IChatDetail, IChatService, IChatSessionTiming } from '../../common/chatService/chatService.js'; +import { chatModelToChatDetail } from '../../common/chatService/chatServiceImpl.js'; import { ChatSessionStatus, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js'; import { IChatModel } from '../../common/model/chatModel.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { getInProgressSessionDescription } from '../chatSessions/chatSessionDescription.js'; +import { chatResponseStateToSessionStatus, getSessionStatusForModel } from '../chatSessions/chatSessions.contribution.js'; export class LocalAgentsSessionsController extends Disposable implements IChatSessionItemController, IWorkbenchContribution { @@ -26,16 +28,16 @@ export class LocalAgentsSessionsController extends Disposable implements IChatSe readonly chatSessionType = localChatSessionType; - private readonly _onDidChange = this._register(new Emitter()); - readonly onDidChange = this._onDidChange.event; - readonly _onDidChangeChatSessionItems = this._register(new Emitter()); readonly onDidChangeChatSessionItems = this._onDidChangeChatSessionItems.event; + private readonly _modelListeners = this._register(new DisposableResourceMap()); + + private _isDisposed = false; + constructor( @IChatService private readonly chatService: IChatService, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, - @ILogService private readonly logService: ILogService, ) { super(); @@ -44,44 +46,82 @@ export class LocalAgentsSessionsController extends Disposable implements IChatSe this.registerListeners(); } - private _items: IChatSessionItem[] = []; + override dispose(): void { + this._isDisposed = true; + super.dispose(); + } + + private _items = new ResourceMap(); get items(): readonly IChatSessionItem[] { - return this._items; + return Array.from(this._items.values()); } async refresh(token: CancellationToken): Promise { - this._items = await this.provideChatSessionItems(token); + const newItems = await this.provideChatSessionItems(token); + + this._items.clear(); + for (const item of newItems) { + this._items.set(item.resource, item); + } } private registerListeners(): void { - this._register(this.chatService.registerChatModelChangeListeners(Schemas.vscodeLocalChatSession, async sessionResource => { - if (getChatSessionType(sessionResource) !== this.chatSessionType) { + const addModelListeners = async (model: IChatModel) => { + if (getChatSessionType(model.sessionResource) !== this.chatSessionType) { return; } - // TODO: This gets fired too often await this.refresh(CancellationToken.None); - const item = this.getItem(sessionResource); - - if (item) { - this._onDidChangeChatSessionItems.fire({ addedOrUpdated: [item] }); + if (this._isDisposed) { + return; } - })); + + this.tryUpdateLiveSessionItem(model); + + const requestChangeListener = model.lastRequestObs.map(last => last?.response && observableSignalFromEvent('chatSessions.modelRequestChangeListener', last.response.onDidChange)); + const modelChangeListener = observableSignalFromEvent('chatSessions.modelChangeListener', model.onDidChange); + this._modelListeners.set(model.sessionResource, autorun(reader => { + requestChangeListener.read(reader)?.read(reader); + modelChangeListener.read(reader); + + this.tryUpdateLiveSessionItem(model); + })); + }; + + this._register(this.chatService.onDidCreateModel(model => addModelListeners(model))); + for (const model of this.chatService.chatModels.get()) { + addModelListeners(model); + } this._register(this.chatService.onDidDisposeSession(e => { - const removedSessionResources = e.sessionResource.filter(resource => getChatSessionType(resource) === this.chatSessionType); + for (const sessionResource of e.sessionResources) { + this._modelListeners.deleteAndDispose(sessionResource); + } + + const removedSessionResources = e.sessionResources.filter(resource => getChatSessionType(resource) === this.chatSessionType); if (removedSessionResources.length) { this._onDidChangeChatSessionItems.fire({ removed: removedSessionResources }); } })); } - private getItem(sessionResource: URI): IChatSessionItem | undefined { - return this._items.find(item => isEqual(item.resource, sessionResource)); + private async tryUpdateLiveSessionItem(model: IChatModel): Promise { + const existing = this._items.get(model.sessionResource); + if (!existing) { + return; + } + + const updated = new LocalChatSessionItem(await chatModelToChatDetail(model), model); + if (existing.isEqual(updated)) { + return; + } + + this._items.set(existing.resource, updated); + this._onDidChangeChatSessionItems.fire({ addedOrUpdated: [updated] }); } - private async provideChatSessionItems(token: CancellationToken): Promise { - const sessions: IChatSessionItem[] = []; + private async provideChatSessionItems(token: CancellationToken): Promise { + const sessions: LocalChatSessionItem[] = []; const sessionsByResource = new ResourceSet(); for (const sessionDetail of await this.chatService.getLiveSessionItems()) { @@ -102,7 +142,7 @@ export class LocalAgentsSessionsController extends Disposable implements IChatSe return sessions; } - private async getHistoryItems(): Promise { + private async getHistoryItems(): Promise { try { const historyItems = await this.chatService.getHistorySessionItems(); @@ -112,72 +152,53 @@ export class LocalAgentsSessionsController extends Disposable implements IChatSe } } - private toChatSessionItem(chat: IChatDetail): IChatSessionItem | undefined { + private toChatSessionItem(chat: IChatDetail): LocalChatSessionItem | undefined { const model = this.chatService.getSession(chat.sessionResource); - let description: string | undefined; if (model) { if (!model.hasRequests) { return undefined; // ignore sessions without requests } - - description = getInProgressSessionDescription(model); } else if (chat.isActive) { // Sessions that are active but don't have a chat model are ultimately untitled with no requests return undefined; } - return { - resource: chat.sessionResource, - label: chat.title, - description, - status: model ? this.modelToStatus(model) : this.chatResponseStateToStatus(chat.lastResponseState), - iconPath: Codicon.chatSparkle, - timing: convertLegacyChatSessionTiming(chat.timing), - changes: chat.stats ? { - insertions: chat.stats.added, - deletions: chat.stats.removed, - files: chat.stats.fileCount, - } : undefined - }; + return new LocalChatSessionItem(chat, model); } +} - private modelToStatus(model: IChatModel): ChatSessionStatus | undefined { - if (model.requestInProgress.get()) { - this.logService.trace(`[agent sessions] Session ${model.sessionResource.toString()} request is in progress.`); - return ChatSessionStatus.InProgress; - } - - const lastRequest = model.getRequests().at(-1); - this.logService.trace(`[agent sessions] Session ${model.sessionResource.toString()} last request response: state ${lastRequest?.response?.state}, isComplete ${lastRequest?.response?.isComplete}, isCanceled ${lastRequest?.response?.isCanceled}, error: ${lastRequest?.response?.result?.errorDetails?.message}.`); - if (lastRequest?.response) { - if (lastRequest.response.state === ResponseModelState.NeedsInput) { - return ChatSessionStatus.NeedsInput; - } else if (lastRequest.response.isCanceled || lastRequest.response.result?.errorDetails?.code === 'canceled') { - return ChatSessionStatus.Completed; - } else if (lastRequest.response.result?.errorDetails) { - return ChatSessionStatus.Failed; - } else if (lastRequest.response.isComplete) { - return ChatSessionStatus.Completed; - } else { - return ChatSessionStatus.InProgress; - } - } - - return undefined; +class LocalChatSessionItem implements IChatSessionItem { + readonly resource: URI; + readonly iconPath = Codicon.chatSparkle; + + readonly label: string; + readonly description: string | undefined; + readonly status: ChatSessionStatus | undefined; + readonly timing: IChatSessionTiming; + readonly changes: IChatSessionItem['changes']; + + constructor(chatDetail: IChatDetail, model: IChatModel | undefined) { + this.resource = chatDetail.sessionResource; + this.label = chatDetail.title; + this.description = model ? getInProgressSessionDescription(model) : undefined; + this.status = (model && getSessionStatusForModel(model)) ?? chatResponseStateToSessionStatus(chatDetail.lastResponseState); + this.timing = convertLegacyChatSessionTiming(chatDetail.timing); + this.changes = chatDetail.stats ? { + insertions: chatDetail.stats.added, + deletions: chatDetail.stats.removed, + files: chatDetail.stats.fileCount, + } : undefined; } - private chatResponseStateToStatus(state: ResponseModelState): ChatSessionStatus { - switch (state) { - case ResponseModelState.Cancelled: - case ResponseModelState.Complete: - return ChatSessionStatus.Completed; - case ResponseModelState.Failed: - return ChatSessionStatus.Failed; - case ResponseModelState.Pending: - return ChatSessionStatus.InProgress; - case ResponseModelState.NeedsInput: - return ChatSessionStatus.NeedsInput; - } + isEqual(other: LocalChatSessionItem): boolean { + return isEqual(this.resource, other.resource) + && this.label === other.label + && this.description === other.description + && this.status === other.status + && this.timing.created === other.timing.created + && this.timing.lastRequestStarted === other.timing.lastRequestStarted + && this.timing.lastRequestEnded === other.timing.lastRequestEnded + && equals(this.changes, other.changes); } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 9f7c9b7a9ee72..2d2af11f3c1fd 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -55,10 +55,12 @@ import { OS } from '../../../../../base/common/platform.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { ICustomizationHarnessService, IExternalCustomizationItem, IExternalCustomizationItemProvider, matchesWorkspaceSubpath, matchesInstructionFileFilter } from '../../common/customizationHarnessService.js'; import { evaluateApplyToPattern } from '../../common/promptSyntax/computeAutomaticInstructions.js'; -import { isInClaudeRulesFolder, getCleanPromptName } from '../../common/promptSyntax/config/promptFileLocations.js'; +import { isInClaudeRulesFolder, getCleanPromptName, AGENT_MD_FILENAME, CLAUDE_MD_FILENAME, CLAUDE_LOCAL_MD_FILENAME, COPILOT_CUSTOM_INSTRUCTIONS_FILENAME } from '../../common/promptSyntax/config/promptFileLocations.js'; +import { PromptHeader } from '../../common/promptSyntax/promptFileParser.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IUserDataProfileService } from '../../../../services/userDataProfile/common/userDataProfile.js'; export { truncateToFirstLine } from './aiCustomizationListWidgetUtils.js'; @@ -279,6 +281,21 @@ export function formatDisplayName(name: string): string { return name.replace(/\.md$/i, ''); } +/** + * Well-known agent instruction filenames that are always loaded and + * grouped under "Agent Instructions" rather than classified by `applyTo`. + */ +const AGENT_INSTRUCTION_FILENAMES = new Set([ + AGENT_MD_FILENAME.toLowerCase(), + CLAUDE_MD_FILENAME.toLowerCase(), + CLAUDE_LOCAL_MD_FILENAME.toLowerCase(), + COPILOT_CUSTOM_INSTRUCTIONS_FILENAME.toLowerCase(), +]); + +function isAgentInstructionFile(uri: URI): boolean { + return AGENT_INSTRUCTION_FILENAMES.has(basename(uri).toLowerCase()); +} + /** * Renderer for AI customization list items. */ @@ -556,6 +573,7 @@ export class AICustomizationListWidget extends Disposable { @IAgentPluginService private readonly agentPluginService: IAgentPluginService, @ICommandService private readonly commandService: ICommandService, @IProductService private readonly productService: IProductService, + @IUserDataProfileService private readonly userDataProfileService: IUserDataProfileService, ) { super(); this.element = $('.ai-customization-list-widget'); @@ -1164,7 +1182,12 @@ export class AICustomizationListWidget extends Disposable { // instead of querying promptsService and applying filters. const activeDescriptor = this.harnessService.getActiveDescriptor(); if (activeDescriptor.itemProvider && promptType) { - return this.fetchItemsFromProvider(activeDescriptor.itemProvider, promptType); + const items = await this.fetchItemsFromProvider(activeDescriptor.itemProvider, promptType); + // Enrich instruction items that lack groupKey/badge with parsed frontmatter + if (promptType === PromptsType.instructions) { + return this.enrichProviderInstructionItems(items); + } + return items; } const items: IAICustomizationListItem[] = []; @@ -1428,53 +1451,15 @@ export class AICustomizationListWidget extends Disposable { })); for (const { item, parsed } of parseResults) { - const applyTo = evaluateApplyToPattern(parsed?.header, isInClaudeRulesFolder(item.uri)); - const name = parsed?.header?.name; - let description = parsed?.header?.description; - const friendlyName = this.getFriendlyName(name || item.name || getCleanPromptName(item.uri)); - description = description || item.description; - - if (applyTo !== undefined) { - // Context instruction - const badge = applyTo === '**' - ? localize('alwaysAdded', "always added") - : applyTo; - const badgeTooltip = applyTo === '**' - ? localize('alwaysAddedTooltip', "This instruction is automatically included in every interaction.") - : localize('onContextTooltip', "This instruction is automatically included when files matching '{0}' are in context.", applyTo); - items.push({ - id: item.uri.toString(), - uri: item.uri, - name: friendlyName, - filename: this.labelService.getUriLabel(item.uri, { relative: true }), - displayName: friendlyName, - badge, - badgeTooltip, - description: description, - storage: item.storage, - promptType, - typeIcon: storageToIcon(item.storage), - groupKey: 'context-instructions', - pluginUri: item.storage === PromptsStorage.plugin ? item.pluginUri : undefined, - disabled: disabledUris.has(item.uri), - }); - } else { - // On-demand instruction - items.push({ - id: item.uri.toString(), - uri: item.uri, - name: friendlyName, - filename: basename(item.uri), - displayName: friendlyName, - description: description, - storage: item.storage, - promptType, - typeIcon: storageToIcon(item.storage), - groupKey: 'on-demand-instructions', - pluginUri: item.storage === PromptsStorage.plugin ? item.pluginUri : undefined, - disabled: disabledUris.has(item.uri), - }); - } + items.push(this.buildInstructionListItem( + item.uri, + item.name || getCleanPromptName(item.uri), + item.description, + item.storage, + parsed?.header, + disabledUris.has(item.uri), + item.storage === PromptsStorage.plugin ? item.pluginUri : undefined, + )); } } @@ -1546,18 +1531,172 @@ export class AICustomizationListWidget extends Disposable { return allItems .filter(item => item.type === promptType) - .map((item: IExternalCustomizationItem) => ({ - id: item.uri.toString(), - uri: item.uri, - name: item.name, - filename: basename(item.uri), - description: item.description, - promptType, - disabled: false, - })) + .map((item: IExternalCustomizationItem) => { + const pluginUri = this.findPluginUri(item.uri); + const storage = pluginUri ? PromptsStorage.plugin : this.inferStorageFromUri(item.uri); + + return { + id: item.uri.toString(), + uri: item.uri, + name: item.name, + filename: basename(item.uri), + description: item.description, + promptType, + disabled: false, + groupKey: item.groupKey, + badge: item.badge, + badgeTooltip: item.badgeTooltip, + storage, + pluginUri, + }; + }) .sort((a, b) => a.name.localeCompare(b.name)); } + /** + * Post-processes instruction items from an external provider by parsing + * each file's frontmatter to compute `groupKey`, `badge`, and `badgeTooltip` + * when the provider didn't supply them. Items that already have a `groupKey` + * (i.e. the extension set them explicitly) are left untouched. + */ + private async enrichProviderInstructionItems(items: IAICustomizationListItem[]): Promise { + const result: IAICustomizationListItem[] = []; + const toParse = items.filter(item => !item.groupKey); + const passThrough = items.filter(item => !!item.groupKey); + + const parseResults = await Promise.all(toParse.map(async item => { + try { + const parsed = await this.promptsService.parseNew(item.uri, CancellationToken.None); + return { item, parsed }; + } catch { + return { item, parsed: undefined }; + } + })); + + for (const { item, parsed } of parseResults) { + result.push(this.buildInstructionListItem( + item.uri, + item.name, + item.description, + item.storage ?? PromptsStorage.local, + parsed?.header, + item.disabled, + item.pluginUri, + )); + } + + result.push(...passThrough); + result.sort((a, b) => a.name.localeCompare(b.name)); + return result; + } + + /** + * Builds an instruction list item with proper groupKey and badge based on + * parsed frontmatter. Shared between the built-in and provider item paths. + */ + private buildInstructionListItem( + uri: URI, + rawName: string, + rawDescription: string | undefined, + storage: PromptsStorage, + header: PromptHeader | undefined, + disabled: boolean, + pluginUri?: URI, + ): IAICustomizationListItem { + const name = header?.name; + const description = header?.description ?? rawDescription; + const friendlyName = this.getFriendlyName(name || rawName || getCleanPromptName(uri)); + const filename = basename(uri); + + // Agent instruction files (AGENTS.md, CLAUDE.md, copilot-instructions.md) + // are always-loaded and get their own group without applyTo badges. + if (isAgentInstructionFile(uri)) { + return { + id: uri.toString(), + uri, + name: friendlyName, + filename: this.labelService.getUriLabel(uri, { relative: true }), + displayName: friendlyName, + description, + storage, + promptType: PromptsType.instructions, + typeIcon: storageToIcon(storage), + groupKey: 'agent-instructions', + pluginUri, + disabled, + }; + } + + const applyTo = evaluateApplyToPattern(header, isInClaudeRulesFolder(uri)); + if (applyTo !== undefined) { + const badge = applyTo === '**' + ? localize('alwaysAdded', "always added") + : applyTo; + const badgeTooltip = applyTo === '**' + ? localize('alwaysAddedTooltip', "This instruction is automatically included in every interaction.") + : localize('onContextTooltip', "This instruction is automatically included when files matching '{0}' are in context.", applyTo); + return { + id: uri.toString(), + uri, + name: friendlyName, + filename: this.labelService.getUriLabel(uri, { relative: true }), + displayName: friendlyName, + badge, + badgeTooltip, + description, + storage, + promptType: PromptsType.instructions, + typeIcon: storageToIcon(storage), + groupKey: 'context-instructions', + pluginUri, + disabled, + }; + } + + return { + id: uri.toString(), + uri, + name: friendlyName, + filename, + displayName: friendlyName, + description, + storage, + promptType: PromptsType.instructions, + typeIcon: storageToIcon(storage), + groupKey: 'on-demand-instructions', + pluginUri, + disabled, + }; + } + + /** + * Infers the storage source of a URI by checking workspace folders, + * user data paths, and plugin locations. + */ + private inferStorageFromUri(uri: URI): PromptsStorage { + // Check if under a workspace folder + if (this.workspaceContextService.getWorkspaceFolder(uri)) { + return PromptsStorage.local; + } + + // Check if under the active project root (Sessions window may use + // an active root that is not a workspace folder, e.g. worktree/repo) + const activeProjectRoot = this.workspaceService.getActiveProjectRoot(); + if (activeProjectRoot && isEqualOrParent(uri, activeProjectRoot)) { + return PromptsStorage.local; + } + + // Check if under user data prompts home + const promptsHome = this.userDataProfileService.currentProfile.promptsHome; + if (isEqualOrParent(uri, promptsHome)) { + return PromptsStorage.user; + } + + // Default to user for remaining files (e.g. user-scoped files + // outside the recognized prompts home) + return PromptsStorage.user; + } + /** * Derives a friendly name from a filename by removing extension suffixes. */ @@ -1608,16 +1747,78 @@ export class AICustomizationListWidget extends Disposable { } } - // When items come from an external provider, skip storage-based grouping - // and render a flat list. The provider controls the full item set, so - // Workspace/User/Extension categories don't apply. + // When items come from an external provider, group by groupKey if + // any items define one; otherwise fall through to standard + // storage-based grouping (storage is auto-inferred from URI). const activeDescriptor = this.harnessService.getActiveDescriptor(); if (activeDescriptor.itemProvider) { - matchedItems.sort((a, b) => a.name.localeCompare(b.name)); - this.displayEntries = matchedItems.map(item => ({ type: 'file-item' as const, item })); - this.list.splice(0, this.list.length, this.displayEntries); - this.updateEmptyState(); - return matchedItems.length; + const hasExplicitGroups = matchedItems.some(item => item.groupKey !== undefined); + if (hasExplicitGroups) { + // Auto-build group definitions from the items' groupKey values, + // preserving insertion order. Items without a groupKey are + // placed into a fallback "Other" group. Uses a Map for O(1) + // lookups instead of repeated array scans. + const ungroupedKey = '__ungrouped__'; + const groupsMap = new Map(); + + for (const item of matchedItems) { + const key = item.groupKey ?? ungroupedKey; + let group = groupsMap.get(key); + if (!group) { + group = { + groupKey: key, + label: key === ungroupedKey ? localize('otherGroup', "Other") : key, + icon: this.getSectionIcon(), + description: '', + items: [], + }; + groupsMap.set(key, group); + } + group.items.push(item); + } + + const groups = Array.from(groupsMap.values()); + + // Sort items within each group + for (const group of groups) { + group.items.sort((a, b) => a.name.localeCompare(b.name)); + } + + // Build display entries with group headers + this.displayEntries = []; + let isFirstGroup = true; + for (const group of groups) { + if (group.items.length === 0) { + continue; + } + + const collapsed = this.collapsedGroups.has(group.groupKey); + + this.displayEntries.push({ + type: 'group-header', + id: `group-${group.groupKey}`, + groupKey: group.groupKey, + label: group.label, + icon: group.icon, + count: group.items.length, + isFirst: isFirstGroup, + description: group.description, + collapsed, + }); + isFirstGroup = false; + + if (!collapsed) { + for (const item of group.items) { + this.displayEntries.push({ type: 'file-item', item }); + } + } + } + + this.list.splice(0, this.list.length, this.displayEntries); + this.updateEmptyState(); + return matchedItems.length; + } + // No explicit groupKey — fall through to standard storage-based grouping below. } // Group items by storage diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index ca72356a43761..86a155d089867 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -1321,6 +1321,15 @@ configurationRegistry.registerConfiguration({ mode: 'auto' } }, + [ChatConfiguration.SignInTitleBarEnabled]: { + type: 'boolean', + description: nls.localize('chat.signInTitleBar', "Controls whether to show a sign-in button in the title bar for users who are not signed in."), + default: false, + tags: ['experimental'], + experiment: { + mode: 'auto' + } + }, [ChatConfiguration.RestoreLastPanelSession]: { type: 'boolean', description: nls.localize('chat.restoreLastPanelSession', "Controls whether the last session is restored in panel after restart."), diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts index a9ef384059594..2d3cf87cfc457 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts @@ -357,7 +357,6 @@ export class ViewAllSessionChangesAction extends Action2 { id: MenuId.ChatEditingSessionChangesToolbar, group: 'navigation', order: 10, - when: ChatContextKeys.hasAgentSessionChanges } ], }); diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingServiceImpl.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingServiceImpl.ts index 11a6ce23b97dc..a8d49698b6e04 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingServiceImpl.ts @@ -87,7 +87,7 @@ export class ChatEditingService extends Disposable implements IChatEditingServic this._register(this._chatService.onDidDisposeSession((e) => { if (e.reason === 'cleared') { - for (const resource of e.sessionResource) { + for (const resource of e.sessionResources) { this.getEditingSession(resource)?.stop(); } } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts index 37a0f2466aa72..f585c7f09962c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts @@ -786,7 +786,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio try { const data = await this._fileService.readFile(contentSource); afterSnapshot = data.value.toString(); - } catch { + } catch (_e) { afterSnapshot = ''; } } else { 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 ac05e2d146972..b7f2d681bf31a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -31,11 +31,11 @@ import { ExtensionsRegistry } from '../../../../services/extensions/common/exten import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js'; import { IChatAgentAttachmentCapabilities, IChatAgentData, IChatAgentService } from '../../common/participants/chatAgents.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { ChatSessionOptionsMap, IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionCustomizationItemGroup, IChatSessionCustomizationsProvider, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsChangeEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsExtensionPoint, IChatSessionsService, isSessionInProgressStatus, ReadonlyChatSessionOptionsMap, ResolvedChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; +import { ChatSessionOptionsMap, ChatSessionStatus, IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionCustomizationItemGroup, IChatSessionCustomizationsProvider, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsChangeEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsExtensionPoint, IChatSessionsService, isSessionInProgressStatus, ReadonlyChatSessionOptionsMap, ResolvedChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { IChatEditorOptions } from '../widgetHosts/editor/chatEditor.js'; -import { IChatService } from '../../common/chatService/chatService.js'; +import { IChatService, ResponseModelState } from '../../common/chatService/chatService.js'; import { autorun, observableFromEvent } from '../../../../../base/common/observable.js'; import { IChatRequestVariableEntry, PromptFileVariableKind, toPromptFileVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; @@ -52,6 +52,7 @@ import { slashReg } from '../../common/requestParser/chatRequestParser.js'; import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; +import { IChatModel } from '../../common/model/chatModel.js'; const extensionPoint = ExtensionsRegistry.registerExtensionPoint({ extensionPoint: 'chatSessions', @@ -1402,3 +1403,40 @@ export function getResourceForNewChatSession(options: NewChatSessionOpenOptions) function isAgentSessionProviderType(type: string): boolean { return Object.values(AgentSessionProviders).includes(type as AgentSessionProviders); } + +export function getSessionStatusForModel(model: IChatModel): ChatSessionStatus | undefined { + if (model.requestInProgress.get()) { + return ChatSessionStatus.InProgress; + } + + const lastRequest = model.getRequests().at(-1); + if (lastRequest?.response) { + if (lastRequest.response.state === ResponseModelState.NeedsInput) { + return ChatSessionStatus.NeedsInput; + } else if (lastRequest.response.isCanceled || lastRequest.response.result?.errorDetails?.code === 'canceled') { + return ChatSessionStatus.Completed; + } else if (lastRequest.response.result?.errorDetails) { + return ChatSessionStatus.Failed; + } else if (lastRequest.response.isComplete) { + return ChatSessionStatus.Completed; + } else { + return ChatSessionStatus.InProgress; + } + } + + return undefined; +} + +export function chatResponseStateToSessionStatus(state: ResponseModelState): ChatSessionStatus { + switch (state) { + case ResponseModelState.Cancelled: + case ResponseModelState.Complete: + return ChatSessionStatus.Completed; + case ResponseModelState.Failed: + return ChatSessionStatus.Failed; + case ResponseModelState.Pending: + return ChatSessionStatus.InProgress; + case ResponseModelState.NeedsInput: + return ChatSessionStatus.NeedsInput; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts index d47ef583d28b3..0bd14a6f7c3b3 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts @@ -3,7 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../../../base/common/actions.js'; +import * as dom from '../../../../../base/browser/dom.js'; +import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { IAction, WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../../../base/common/actions.js'; import { Event } from '../../../../../base/common/event.js'; import { Lazy } from '../../../../../base/common/lazy.js'; import { Disposable, DisposableStore, markAsSingleton, MutableDisposable } from '../../../../../base/common/lifecycle.js'; @@ -15,10 +17,12 @@ import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions import { ICodeEditorService } from '../../../../../editor/browser/services/codeEditorService.js'; import { EditorContextKeys } from '../../../../../editor/common/editorContextKeys.js'; import { localize, localize2 } from '../../../../../nls.js'; +import { IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IsWebContext } from '../../../../../platform/contextkey/common/contextkeys.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; @@ -61,11 +65,14 @@ const defaultChat = { chatRefreshTokenCommand: product.defaultChatAgent?.chatRefreshTokenCommand ?? '', }; +const SIGN_IN_TITLE_BAR_ACTION_ID = 'workbench.action.chat.signInIndicator'; + export class ChatSetupContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.chatSetup'; constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IChatEntitlementService chatEntitlementService: ChatEntitlementService, @ILogService private readonly logService: ILogService, @@ -90,6 +97,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr this.registerSetupAgents(context, controller); this.registerGrowthSession(chatEntitlementService); this.registerActions(context, requests, controller); + this.registerSignInTitleBarEntry(actionViewItemService); this.registerUrlLinkHandler(); this.checkExtensionInstallation(context); } @@ -361,6 +369,39 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr } } + class ChatSetupSignInTitleBarAction extends Action2 { + + static readonly ID = SIGN_IN_TITLE_BAR_ACTION_ID; + + constructor() { + super({ + id: ChatSetupSignInTitleBarAction.ID, + title: localize('signInIndicatorTitleBarAction', 'Sign In'), + f1: false, + menu: [{ + id: MenuId.TitleBarAdjacentCenter, + order: 0, // same position as the update button + when: ContextKeyExpr.and( + IsWebContext.negate(), + ContextKeyExpr.has(`config.${ChatConfiguration.SignInTitleBarEnabled}`), + ChatContextKeys.Entitlement.signedOut, + ChatContextKeys.Setup.hidden.negate(), + ContextKeyExpr.has('updateTitleBar').negate() + ), + }] + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const commandService = accessor.get(ICommandService); + const telemetryService = accessor.get(ITelemetryService); + + telemetryService.publicLog2('workbenchActionExecuted', { id: CHAT_SETUP_ACTION_ID, from: 'titlebar' }); + + return commandService.executeCommand(CHAT_SETUP_ACTION_ID); + } + } + const windowFocusListener = this._register(new MutableDisposable()); class UpgradePlanAction extends Action2 { constructor() { @@ -461,6 +502,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr registerAction2(ChatSetupTriggerAction); registerAction2(ChatSetupTriggerForceSignInDialogAction); registerAction2(ChatSetupFromAccountsAction); + registerAction2(ChatSetupSignInTitleBarAction); registerAction2(ChatSetupTriggerAnonymousWithoutDialogAction); registerAction2(ChatSetupTriggerSupportAnonymousAction); registerAction2(UpgradePlanAction); @@ -556,6 +598,14 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr } + private registerSignInTitleBarEntry(actionViewItemService: IActionViewItemService): void { + this._register(actionViewItemService.register( + MenuId.TitleBarAdjacentCenter, + SIGN_IN_TITLE_BAR_ACTION_ID, + (action, options) => new SignInTitleBarEntry(action, options) + )); + } + private registerUrlLinkHandler(): void { this._register(ExtensionUrlHandlerOverrideRegistry.registerHandler(this.instantiationService.createInstance(ChatSetupExtensionUrlHandler))); } @@ -770,3 +820,45 @@ export function refreshTokens(commandService: ICommandService): void { commandService.executeCommand(defaultChat.completionsRefreshTokenCommand); commandService.executeCommand(defaultChat.chatRefreshTokenCommand); } + +/** + * Custom action view item that renders a "Sign In" button + * in the title bar with prominent button styling. + */ +class SignInTitleBarEntry extends BaseActionViewItem { + + private label: HTMLElement | undefined; + + constructor( + action: IAction, + options: IBaseActionViewItemOptions, + ) { + super(undefined, action, options); + } + + public override render(container: HTMLElement) { + super.render(container); + + container.setAttribute('role', 'button'); + container.setAttribute('aria-label', this.action.label); + + const content = dom.append(container, dom.$('.update-indicator.prominent')); + this.label = dom.append(content, dom.$('.indicator-label')); + this.label.textContent = this.action.label; + } + + protected override updateLabel(): void { + if (this.label) { + this.label.textContent = this.action.label; + } + if (this.element) { + this.element.setAttribute('aria-label', this.action.label); + } + } + + protected override updateEnabled(): void { + if (this.element) { + this.element.classList.toggle('disabled', !this.action.enabled); + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts index 0e1160d71e487..38ce308a1a1ca 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts @@ -22,6 +22,7 @@ import { disposableWindowInterval } from '../../../../../base/browser/dom.js'; import { isNewUser } from './chatStatus.js'; import product from '../../../../../platform/product/common/product.js'; import { isCompletionsEnabled } from '../../../../../editor/common/services/completionsEnablement.js'; +import { ChatConfiguration } from '../../common/constants.js'; export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribution { @@ -84,7 +85,7 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu this._register(this.editorService.onDidActiveEditorChange(() => this.onDidActiveEditorChange())); this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(product.defaultChatAgent?.completionsEnablementSetting)) { + if (e.affectsConfiguration(product.defaultChatAgent?.completionsEnablementSetting) || e.affectsConfiguration(ChatConfiguration.SignInTitleBarEnabled)) { this.update(); } })); @@ -147,11 +148,17 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu // Signed out else if (this.chatEntitlementService.entitlement === ChatEntitlement.Unknown) { - const signedOutWarning = localize('notSignedIn', "Signed out"); - - text = `${this.chatEntitlementService.anonymous ? '$(copilot)' : '$(copilot-not-connected)'} ${signedOutWarning}`; - ariaLabel = signedOutWarning; - kind = 'prominent'; + const signInExperiment = this.configurationService.getValue(ChatConfiguration.SignInTitleBarEnabled); + if (signInExperiment) { + const signIn = localize('signIn', "Sign In"); + text = `$(copilot) ${signIn}`; + ariaLabel = signIn; + } else { + const signedOut = localize('notSignedIn', "Signed out"); + text = `${this.chatEntitlementService.anonymous ? '$(copilot)' : '$(copilot-not-connected)'} ${signedOut}`; + ariaLabel = signedOut; + kind = 'prominent'; + } } // Free Quota Exceeded diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts index 2105d64782272..e446839cfed65 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -42,6 +42,11 @@ export interface IChatQuestionCarouselOptions { shouldAutoFocus?: boolean; } +type IOrderedQuestionOption = { + option: NonNullable[number]; + originalIndex: number; +}; + export class ChatQuestionCarouselPart extends Disposable implements IChatContentPart { public readonly domNode: HTMLElement; @@ -67,8 +72,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent private _isSkipped = false; private readonly _textInputBoxes: Map = new Map(); - private readonly _singleSelectItems: Map = new Map(); - private readonly _multiSelectCheckboxes: Map = new Map(); + private readonly _singleSelectItems: Map = new Map(); + private readonly _multiSelectCheckboxes: Map = new Map(); private readonly _freeformTextareas: Map = new Map(); private readonly _inputBoxes: DisposableStore = this._register(new DisposableStore()); private readonly _questionRenderStore = this._register(new MutableDisposable()); @@ -955,7 +960,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } private renderSingleSelect(container: HTMLElement, question: IChatQuestion): void { - const options = question.options || []; + const orderedOptions = this.getOptionsWithDefaultsFirst(question); const selectContainer = dom.$('.chat-question-list'); selectContainer.setAttribute('role', 'listbox'); selectContainer.setAttribute('aria-label', question.title); @@ -973,7 +978,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // Determine initially selected index let selectedIndex = -1; - options.forEach((option, index) => { + orderedOptions.forEach(({ option }, index) => { if (previousSelectedValue !== undefined && option.value === previousSelectedValue) { selectedIndex = index; } else if (selectedIndex === -1 && !previousFreeform && defaultOptionId !== undefined && option.id === defaultOptionId) { @@ -1006,7 +1011,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this.saveCurrentAnswer(); }; - options.forEach((option, index) => { + orderedOptions.forEach(({ option }, index) => { const isSelected = index === selectedIndex; const listItem = dom.$('.chat-question-list-item'); listItem.setAttribute('role', 'option'); @@ -1070,7 +1075,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent listItems.push(listItem); }); - this._singleSelectItems.set(question.id, { items: listItems, selectedIndex }); + this._singleSelectItems.set(question.id, { items: listItems, selectedIndex, optionIndices: orderedOptions.map(o => o.originalIndex) }); // Set initial aria-activedescendant if there's a selected item if (selectedIndex >= 0 && selectedIndex < listItems.length) { @@ -1083,7 +1088,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const freeformContainer = dom.$('.chat-question-freeform'); const freeformNumber = dom.$('.chat-question-freeform-number'); - freeformNumber.textContent = `${options.length + 1}`; + freeformNumber.textContent = `${orderedOptions.length + 1}`; freeformContainer.appendChild(freeformNumber); freeformTextarea = dom.$('textarea.chat-question-freeform-textarea'); @@ -1178,7 +1183,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } private renderMultiSelect(container: HTMLElement, question: IChatQuestion): void { - const options = question.options || []; + const orderedOptions = this.getOptionsWithDefaultsFirst(question); const selectContainer = dom.$('.chat-question-list'); selectContainer.setAttribute('role', 'listbox'); selectContainer.setAttribute('aria-multiselectable', 'true'); @@ -1202,7 +1207,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent let focusedIndex = 0; let firstCheckedIndex = -1; - options.forEach((option, index) => { + orderedOptions.forEach(({ option }, index) => { // Determine initial checked state let isChecked = false; if (previousSelectedValues && previousSelectedValues.length > 0) { @@ -1282,7 +1287,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent listItems.push(listItem); }); - this._multiSelectCheckboxes.set(question.id, checkboxes); + this._multiSelectCheckboxes.set(question.id, { checkboxes, optionIndices: orderedOptions.map(o => o.originalIndex) }); // Show freeform input only when explicitly allowed let freeformTextarea: HTMLTextAreaElement | undefined; @@ -1291,7 +1296,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // Number indicator for freeform (comes after all options) const freeformNumber = dom.$('.chat-question-freeform-number'); - freeformNumber.textContent = `${options.length + 1}`; + freeformNumber.textContent = `${orderedOptions.length + 1}`; freeformContainer.appendChild(freeformNumber); freeformTextarea = dom.$('textarea.chat-question-freeform-textarea'); @@ -1389,7 +1394,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const data = this._singleSelectItems.get(question.id); let selectedValue: string | undefined = undefined; if (data && data.selectedIndex >= 0) { - selectedValue = question.options?.[data.selectedIndex]?.value; + const originalIndex = data.optionIndices[data.selectedIndex]; + selectedValue = originalIndex !== undefined ? question.options?.[originalIndex]?.value : undefined; } // Find default option if nothing selected (defaultValue is the option id) if (selectedValue === undefined && typeof question.defaultValue === 'string') { @@ -1411,12 +1417,13 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } case 'multiSelect': { - const checkboxes = this._multiSelectCheckboxes.get(question.id); + const data = this._multiSelectCheckboxes.get(question.id); const selectedValues: string[] = []; - if (checkboxes) { - checkboxes.forEach((checkbox, index) => { + if (data) { + data.checkboxes.forEach((checkbox, index) => { if (checkbox.checked) { - const value = question.options?.[index]?.value; + const originalIndex = data.optionIndices[index]; + const value = originalIndex !== undefined ? question.options?.[originalIndex]?.value : undefined; if (value !== undefined) { selectedValues.push(value); } @@ -1441,6 +1448,31 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } } + private getOptionsWithDefaultsFirst(question: IChatQuestion): IOrderedQuestionOption[] { + const options = question.options ?? []; + const orderedOptions = options.map((option, index) => ({ option, originalIndex: index })); + const defaultOptionIds = Array.isArray(question.defaultValue) + ? question.defaultValue + : (typeof question.defaultValue === 'string' ? [question.defaultValue] : []); + + if (defaultOptionIds.length === 0) { + return orderedOptions; + } + + const defaultIds = new Set(defaultOptionIds); + const defaults: IOrderedQuestionOption[] = []; + const nonDefaults: IOrderedQuestionOption[] = []; + for (const item of orderedOptions) { + if (defaultIds.has(item.option.id)) { + defaults.push(item); + } else { + nonDefaults.push(item); + } + } + + return [...defaults, ...nonDefaults]; + } + /** * Renders a "Skipped" message when the carousel is dismissed without answers. */ diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTodoListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTodoListWidget.ts index 619d00a2f5878..8f0f2c486bcbc 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTodoListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTodoListWidget.ts @@ -16,6 +16,7 @@ import { localize } from '../../../../../../nls.js'; import { IContextKeyService, IContextKey } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { WorkbenchList } from '../../../../../../platform/list/browser/listService.js'; +import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { IChatTodo, IChatTodoListService } from '../../../common/tools/chatTodoListService.js'; @@ -129,7 +130,8 @@ export class ChatTodoListWidget extends Disposable { constructor( @IChatTodoListService private readonly chatTodoListService: IChatTodoListService, @IInstantiationService private readonly instantiationService: IInstantiationService, - @IContextKeyService private readonly contextKeyService: IContextKeyService + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @ITelemetryService private readonly telemetryService: ITelemetryService ) { super(); @@ -215,6 +217,14 @@ export class ChatTodoListWidget extends Disposable { this._register(this.clearButton); this._register(this.clearButton.onDidClick(() => { + const todoCount = this._currentSessionResource ? this.chatTodoListService.getTodos(this._currentSessionResource).length : 0; + this.telemetryService.publicLog2( + 'chatTodoListWidget', + { + action: 'clear', + todoCount + } + ); this.clearAllTodos(); })); } @@ -350,6 +360,15 @@ export class ChatTodoListWidget extends Disposable { this.todoListContainer.style.display = this._isExpanded ? 'block' : 'none'; + const todoCount = this._currentSessionResource ? this.chatTodoListService.getTodos(this._currentSessionResource).length : 0; + this.telemetryService.publicLog2( + 'chatTodoListWidget', + { + action: this._isExpanded ? 'expand' : 'collapse', + todoCount + } + ); + if (this._currentSessionResource) { const todoList = this.chatTodoListService.getTodos(this._currentSessionResource); this.updateTitleElement(this.titleElement, todoList); @@ -455,3 +474,15 @@ export class ChatTodoListWidget extends Disposable { } } } + +type ChatTodoListWidgetEvent = { + action: 'expand' | 'collapse' | 'clear'; + todoCount: number; +}; + +type ChatTodoListWidgetClassification = { + action: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The user action on the todo list widget (expand, collapse, or clear).' }; + todoCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of todos at the time of the action.' }; + owner: 'bhavyaus'; + comment: 'Tracks user interactions with the chat todo list widget.'; +}; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletionUtils.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletionUtils.ts new file mode 100644 index 0000000000000..405673dd4a6a5 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletionUtils.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Position } from '../../../../../../../editor/common/core/position.js'; +import { Range } from '../../../../../../../editor/common/core/range.js'; +import { IWordAtPosition, getWordAtText } from '../../../../../../../editor/common/core/wordHelper.js'; +import { ITextModel } from '../../../../../../../editor/common/model.js'; + +export function escapeForCharClass(text: string): string { + return text.replace(/[-\\^\]]/g, '\\$&'); +} + +export interface IChatCompletionRangeResult { + insert: Range; + replace: Range; + varWord: IWordAtPosition | null; +} + +export function computeCompletionRanges(model: ITextModel, position: Position, reg: RegExp, onlyOnWordStart = false): IChatCompletionRangeResult | undefined { + const varWord = getWordAtText(position.column, reg, model.getLineContent(position.lineNumber), 0); + if (!varWord && model.getWordUntilPosition(position).word) { + // inside a "normal" word + return; + } + + if (!varWord && position.column > 1) { + const textBefore = model.getValueInRange(new Range(position.lineNumber, position.column - 1, position.lineNumber, position.column)); + if (textBefore !== ' ') { + return; + } + } + + if (varWord && onlyOnWordStart) { + const wordBefore = model.getWordUntilPosition({ lineNumber: position.lineNumber, column: varWord.startColumn }); + if (wordBefore.word) { + // inside a word + return; + } + } + + let insert: Range; + let replace: Range; + if (!varWord) { + insert = replace = Range.fromPositions(position); + } else { + insert = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, position.column); + replace = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, varWord.endColumn); + } + + return { insert, replace, varWord }; +} + +export function isEmptyUpToCompletionWord(model: ITextModel, rangeResult: IChatCompletionRangeResult): boolean { + const startToCompletionWordStart = new Range(1, 1, rangeResult.replace.startLineNumber, rangeResult.replace.startColumn); + return !!model.getValueInRange(startToCompletionWordStart).match(/^\s*$/); +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts index 9295f3e8a19fe..e34a24b8c3685 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts @@ -21,7 +21,7 @@ import { ICodeEditor, getCodeEditor, isCodeEditor } from '../../../../../../../e import { ICodeEditorService } from '../../../../../../../editor/browser/services/codeEditorService.js'; import { Position } from '../../../../../../../editor/common/core/position.js'; import { Range } from '../../../../../../../editor/common/core/range.js'; -import { IWordAtPosition, getWordAtText } from '../../../../../../../editor/common/core/wordHelper.js'; +import { IWordAtPosition } from '../../../../../../../editor/common/core/wordHelper.js'; import { CompletionContext, CompletionItem, CompletionItemKind, CompletionItemProvider, CompletionList, DocumentSymbol, Location, ProviderResult, SymbolKind, SymbolKinds } from '../../../../../../../editor/common/languages.js'; import { ITextModel } from '../../../../../../../editor/common/model.js'; import { ILanguageFeaturesService } from '../../../../../../../editor/common/services/languageFeatures.js'; @@ -68,6 +68,7 @@ import { IChatDebugService } from '../../../../common/chatDebugService.js'; import { createDebugEventsAttachment } from '../../../chatDebug/chatDebugAttachment.js'; import { getPromptFileType } from '../../../../common/promptSyntax/config/promptFileLocations.js'; import { getChatSessionType } from '../../../../common/model/chatUri.js'; +import { computeCompletionRanges, escapeForCharClass, IChatCompletionRangeResult, isEmptyUpToCompletionWord } from './chatInputCompletionUtils.js'; import { getAgentSessionProviderIcon, AgentSessionProviders } from '../../../agentSessions/agentSessions.js'; /** @@ -256,10 +257,6 @@ class SlashCommandCompletions extends Disposable { const userInvocableCommands = promptCommands .filter(c => { if (widget.lockedAgentId) { - // Exclude extension-provided prompt files for locked agents. - if (c.promptPath.extension) { - return false; - } // Exclude hooks as those don't work in locked agent scenarios. try { const promptType = getPromptFileType(c.promptPath.uri); @@ -862,7 +859,7 @@ interface IVariableCompletionsDetails { class BuiltinDynamicCompletions extends Disposable { private static readonly addReferenceCommand = '_addReferenceCmd'; private static readonly addDebugEventsSnapshotCommand = '_addDebugEventsSnapshotCmd'; - private static readonly VariableNameDef = new RegExp(`${chatVariableLeader}[\\w:-]*`, 'g'); // MUST be using `g`-flag + private static readonly VariableNameDef = new RegExp(`[${escapeForCharClass(chatVariableLeader)}${escapeForCharClass(chatAgentLeader)}][\\w:-]*`, 'g'); // MUST be using `g`-flag constructor( @@ -884,7 +881,7 @@ class BuiltinDynamicCompletions extends Disposable { super(); // File/Folder completions in one go and m - const fileWordPattern = new RegExp(`${chatVariableLeader}[^\\s]*`, 'g'); + const fileWordPattern = new RegExp(`[${escapeForCharClass(chatVariableLeader)}${escapeForCharClass(chatAgentLeader)}][^\\s]*`, 'g'); this.registerVariableCompletions('fileAndFolder', async ({ widget, range }, token) => { if (!widget.supportsFileReferences) { return; @@ -925,15 +922,16 @@ class BuiltinDynamicCompletions extends Disposable { return; } + const typedLeader = range.varWord?.word?.charAt(0) === chatAgentLeader ? chatAgentLeader : chatVariableLeader; const basename = this.labelService.getUriBasenameLabel(currentResource); - const text = `${chatVariableLeader}file:${basename}:${currentSelection.startLineNumber}-${currentSelection.endLineNumber}`; + const text = `${typedLeader}file:${basename}:${currentSelection.startLineNumber}-${currentSelection.endLineNumber}`; const fullRangeText = `:${currentSelection.startLineNumber}:${currentSelection.startColumn}-${currentSelection.endLineNumber}:${currentSelection.endColumn}`; const description = this.labelService.getUriLabel(currentResource, { relative: true }) + fullRangeText; const result: CompletionList = { suggestions: [] }; result.suggestions.push({ - label: { label: `${chatVariableLeader}selection`, description }, - filterText: `${chatVariableLeader}selection`, + label: { label: `${typedLeader}selection`, description }, + filterText: `${typedLeader}selection`, insertText: range.varWord?.endColumn === range.replace.endColumn ? `${text} ` : text, range, kind: CompletionItemKind.Text, @@ -957,7 +955,7 @@ class BuiltinDynamicCompletions extends Disposable { } const result: CompletionList = { suggestions: [] }; - const range2 = computeCompletionRanges(model, position, new RegExp(`${chatVariableLeader}[^\\s]*`, 'g'), true); + const range2 = computeCompletionRanges(model, position, new RegExp(`[${escapeForCharClass(chatVariableLeader)}${escapeForCharClass(chatAgentLeader)}][^\\s]*`, 'g'), true); if (range2) { this.addSymbolEntries(widget, result, range2, token); } @@ -1107,7 +1105,7 @@ class BuiltinDynamicCompletions extends Disposable { private registerVariableCompletions(debugName: string, provider: (details: IVariableCompletionsDetails, token: CancellationToken) => ProviderResult, wordPattern: RegExp = BuiltinDynamicCompletions.VariableNameDef) { this._register(this.languageFeaturesService.completionProvider.register({ scheme: Schemas.vscodeChatInput, hasAccessToAllModels: true }, { _debugDisplayName: `chatVarCompletions-${debugName}`, - triggerCharacters: [chatVariableLeader], + triggerCharacters: [chatVariableLeader, chatAgentLeader], provideCompletionItems: async (model: ITextModel, position: Position, context: CompletionContext, token: CancellationToken) => { const widget = this.chatWidgetService.getWidgetByInputUri(model.uri); if (!widget) { @@ -1128,9 +1126,11 @@ class BuiltinDynamicCompletions extends Disposable { private async addFileAndFolderEntries(widget: IChatWidget, result: CompletionList, info: { insert: Range; replace: Range; varWord: IWordAtPosition | null }, token: CancellationToken) { + const typedLeader = info.varWord?.word?.charAt(0) === chatAgentLeader ? chatAgentLeader : chatVariableLeader; + const makeCompletionItem = (resource: URI, kind: FileKind, description?: string, boostPriority?: boolean): CompletionItem => { const basename = this.labelService.getUriBasenameLabel(resource); - const text = `${chatVariableLeader}file:${basename}`; + const text = `${typedLeader}file:${basename}`; const uriLabel = this.labelService.getUriLabel(resource, { relative: true }); const labelDescription = description ? localize('fileEntryDescription', '{0} ({1})', uriLabel, description) @@ -1140,7 +1140,7 @@ class BuiltinDynamicCompletions extends Disposable { return { label: { label: basename, description: labelDescription }, - filterText: `${chatVariableLeader}${basename}`, + filterText: `${basename} ${typedLeader}${basename} ${uriLabel}`, insertText: info.varWord?.endColumn === info.replace.endColumn ? `${text} ` : text, range: info, kind: kind === FileKind.FILE ? CompletionItemKind.File : CompletionItemKind.Folder, @@ -1158,8 +1158,8 @@ class BuiltinDynamicCompletions extends Disposable { }; let pattern: string | undefined; - if (info.varWord?.word && info.varWord.word.startsWith(chatVariableLeader)) { - pattern = info.varWord.word.toLowerCase().slice(1); // remove leading # + if (info.varWord?.word && (info.varWord.word.startsWith(chatVariableLeader) || info.varWord.word.startsWith(chatAgentLeader))) { + pattern = info.varWord.word.toLowerCase().slice(1); // remove leading # or @ } const seen = new ResourceSet(); @@ -1176,8 +1176,10 @@ class BuiltinDynamicCompletions extends Disposable { if (pattern) { // use pattern if available + const uriLabel = this.labelService.getUriLabel(resource, { relative: true }).toLowerCase(); const basename = this.labelService.getUriBasenameLabel(resource).toLowerCase(); - if (!isPatternInWord(pattern, 0, pattern.length, basename, 0, basename.length)) { + const combined = `${basename} ${uriLabel}`; + if (!isPatternInWord(pattern, 0, pattern.length, combined, 0, combined.length)) { continue; } } @@ -1222,15 +1224,17 @@ class BuiltinDynamicCompletions extends Disposable { const timeoutMs = 100; const stopwatch = new StopWatch(); + const typedLeader = info.varWord?.word?.charAt(0) === chatAgentLeader ? chatAgentLeader : chatVariableLeader; + const makeSymbolCompletionItem = (symbolItem: { name: string; location: Location; kind: SymbolKind }, pattern: string): CompletionItem => { - const text = `${chatVariableLeader}sym:${symbolItem.name}`; + const text = `${typedLeader}sym:${symbolItem.name}`; const resource = symbolItem.location.uri; const uriLabel = this.labelService.getUriLabel(resource, { relative: true }); const sortText = pattern ? '{' /* after z */ : '|' /* after { */; return { label: { label: symbolItem.name, description: uriLabel }, - filterText: `${chatVariableLeader}${symbolItem.name}`, + filterText: `${typedLeader}${symbolItem.name}`, insertText: info.varWord?.endColumn === info.replace.endColumn ? `${text} ` : text, range: info, kind: SymbolKinds.toCompletionKind(symbolItem.kind), @@ -1248,8 +1252,8 @@ class BuiltinDynamicCompletions extends Disposable { }; let pattern: string | undefined; - if (info.varWord?.word && info.varWord.word.startsWith(chatVariableLeader)) { - pattern = info.varWord.word.toLowerCase().slice(1); // remove leading # + if (info.varWord?.word && (info.varWord.word.startsWith(chatVariableLeader) || info.varWord.word.startsWith(chatAgentLeader))) { + pattern = info.varWord.word.toLowerCase().slice(1); // remove leading # or @ } const symbolsToAdd: { symbol: DocumentSymbol; uri: URI }[] = []; @@ -1299,54 +1303,9 @@ class BuiltinDynamicCompletions extends Disposable { Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(BuiltinDynamicCompletions, LifecyclePhase.Eventually); -export interface IChatCompletionRangeResult { - insert: Range; - replace: Range; - varWord: IWordAtPosition | null; -} - -export function computeCompletionRanges(model: ITextModel, position: Position, reg: RegExp, onlyOnWordStart = false): IChatCompletionRangeResult | undefined { - const varWord = getWordAtText(position.column, reg, model.getLineContent(position.lineNumber), 0); - if (!varWord && model.getWordUntilPosition(position).word) { - // inside a "normal" word - return; - } - - if (!varWord && position.column > 1) { - const textBefore = model.getValueInRange(new Range(position.lineNumber, position.column - 1, position.lineNumber, position.column)); - if (textBefore !== ' ') { - return; - } - } - - if (varWord && onlyOnWordStart) { - const wordBefore = model.getWordUntilPosition({ lineNumber: position.lineNumber, column: varWord.startColumn }); - if (wordBefore.word) { - // inside a word - return; - } - } - - let insert: Range; - let replace: Range; - if (!varWord) { - insert = replace = Range.fromPositions(position); - } else { - insert = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, position.column); - replace = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, varWord.endColumn); - } - - return { insert, replace, varWord }; -} - -function isEmptyUpToCompletionWord(model: ITextModel, rangeResult: IChatCompletionRangeResult): boolean { - const startToCompletionWordStart = new Range(1, 1, rangeResult.replace.startLineNumber, rangeResult.replace.startColumn); - return !!model.getValueInRange(startToCompletionWordStart).match(/^\s*$/); -} - class ToolCompletions extends Disposable { - private static readonly VariableNameDef = new RegExp(`(?<=^|\\s)${chatVariableLeader}\\w*`, 'g'); // MUST be using `g`-flag + private static readonly VariableNameDef = new RegExp(`(?<=^|\\s)[${escapeForCharClass(chatVariableLeader)}${escapeForCharClass(chatAgentLeader)}]\\w*`, 'g'); // MUST be using `g`-flag constructor( @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, @@ -1357,7 +1316,7 @@ class ToolCompletions extends Disposable { this._register(this.languageFeaturesService.completionProvider.register({ scheme: Schemas.vscodeChatInput, hasAccessToAllModels: true }, { _debugDisplayName: 'chatVariables', - triggerCharacters: [chatVariableLeader], + triggerCharacters: [chatVariableLeader, chatAgentLeader], provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, _token: CancellationToken) => { const widget = this.chatWidgetService.getWidgetByInputUri(model.uri); if (!widget) { @@ -1387,6 +1346,8 @@ class ToolCompletions extends Disposable { } } + const typedLeader = range.varWord?.word?.charAt(0) === chatAgentLeader ? chatAgentLeader : chatVariableLeader; + const pattern = range.varWord?.word ? range.varWord.word.toLowerCase().slice(1) : ''; const suggestions: CompletionItem[] = []; @@ -1416,12 +1377,20 @@ class ToolCompletions extends Disposable { continue; } - const withLeader = `${chatVariableLeader}${name}`; + if (pattern) { + const lowerName = name.toLowerCase(); + if (!isPatternInWord(pattern, 0, pattern.length, lowerName, 0, lowerName.length)) { + continue; + } + } + + const withLeader = `${typedLeader}${name}`; suggestions.push({ label: withLeader, range, detail, documentation, + filterText: `${typedLeader}${name}`, insertText: withLeader + ' ', kind: CompletionItemKind.Tool, }); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index d748c114514c3..b2665e2ce9842 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -8,7 +8,7 @@ import { DeferredPromise } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Event } from '../../../../../base/common/event.js'; import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; -import { DisposableStore, IDisposable, IReference } from '../../../../../base/common/lifecycle.js'; +import { DisposableStore, IReference } from '../../../../../base/common/lifecycle.js'; import { autorun, autorunSelfDisposable, IObservable, IReader } from '../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { hasKey } from '../../../../../base/common/types.js'; @@ -1512,7 +1512,7 @@ export interface IChatService { readonly onDidReceiveQuestionCarouselAnswer: Event<{ requestId: string; resolveId: string; answers: IChatQuestionAnswers | undefined }>; notifyQuestionCarouselAnswer(requestId: string, resolveId: string, answers: IChatQuestionAnswers | undefined): void; - readonly onDidDisposeSession: Event<{ readonly sessionResource: readonly URI[]; readonly reason: 'cleared' }>; + readonly onDidDisposeSession: Event<{ readonly sessionResources: readonly URI[]; readonly reason: 'cleared' }>; transferChatSession(transferredSessionResource: URI, toWorkspace: URI): Promise; @@ -1520,11 +1520,6 @@ export interface IChatService { readonly requestInProgressObs: IObservable; - /** - * @deprecated - */ - registerChatModelChangeListeners(chatSessionType: string, onChange: (chatSessionResource: URI) => void): IDisposable; - /** * For tests only! */ diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 3581db7b410b5..387af5837799d 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -10,10 +10,10 @@ import { BugIndicatingError, ErrorNoTelemetry } from '../../../../../base/common import { Emitter, Event } from '../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { Iterable } from '../../../../../base/common/iterator.js'; -import { Disposable, DisposableResourceMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableResourceMap, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { revive } from '../../../../../base/common/marshalling.js'; import { Schemas } from '../../../../../base/common/network.js'; -import { autorun, autorunIterableDelta, derived, IObservable, ISettableObservable, observableSignalFromEvent, observableValue } from '../../../../../base/common/observable.js'; +import { autorun, derived, IObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; import { isEqual } from '../../../../../base/common/resources.js'; import { StopWatch } from '../../../../../base/common/stopwatch.js'; import { isDefined } from '../../../../../base/common/types.js'; @@ -56,7 +56,6 @@ import { ChatSessionOperationLog } from '../model/chatSessionOperationLog.js'; import { IPromptsService } from '../promptSyntax/service/promptsService.js'; import { AGENT_DEBUG_LOG_ENABLED_SETTING, AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING, TROUBLESHOOT_COMMAND_NAME, TROUBLESHOOT_SKILL_PATH, COPILOT_SKILL_URI_SCHEME } from '../promptSyntax/promptTypes.js'; import { ChatRequestHooks, mergeHooks } from '../promptSyntax/hookSchema.js'; -import { ResourceMap } from '../../../../../base/common/map.js'; import { findLast } from '../../../../../base/common/arraysFind.js'; import { ChatMode } from '../chatModes.js'; @@ -126,7 +125,7 @@ export class ChatService extends Disposable implements IChatService { private readonly _onDidReceiveQuestionCarouselAnswer = this._register(new Emitter<{ requestId: string; resolveId: string; answers: IChatQuestionAnswers | undefined }>()); public readonly onDidReceiveQuestionCarouselAnswer = this._onDidReceiveQuestionCarouselAnswer.event; - private readonly _onDidDisposeSession = this._register(new Emitter<{ readonly sessionResource: URI[]; reason: 'cleared' }>()); + private readonly _onDidDisposeSession = this._register(new Emitter<{ readonly sessionResources: URI[]; reason: 'cleared' }>()); public readonly onDidDisposeSession = this._onDidDisposeSession.event; private readonly _sessionFollowupCancelTokens = this._register(new DisposableResourceMap()); @@ -195,7 +194,7 @@ export class ChatService extends Disposable implements IChatService { this._register(this._sessionModels.onDidDisposeModel(model => { clearChatMarks(model.sessionResource); this.chatDebugService.endSession(model.sessionResource); - this._onDidDisposeSession.fire({ sessionResource: [model.sessionResource], reason: 'cleared' }); + this._onDidDisposeSession.fire({ sessionResources: [model.sessionResource], reason: 'cleared' }); })); this._chatServiceTelemetry = this.instantiationService.createInstance(ChatServiceTelemetry); @@ -400,18 +399,7 @@ export class ChatService extends Disposable implements IChatService { async getLiveSessionItems(): Promise { return await Promise.all(Array.from(this._sessionModels.values()) .filter(session => this.shouldBeInHistory(session)) - .map(async (session): Promise => { - const title = session.title || localize('newChat', "New Chat"); - return { - sessionResource: session.sessionResource, - title, - lastMessageDate: session.lastMessageDate, - timing: session.timing, - isActive: true, - stats: await awaitStatsForSession(session), - lastResponseState: session.lastRequest?.response?.state ?? ResponseModelState.Pending, - }; - })); + .map(chatModelToChatDetail)); } /** @@ -452,7 +440,7 @@ export class ChatService extends Disposable implements IChatService { async removeHistoryEntry(sessionResource: URI): Promise { await this._chatSessionStore.deleteSession(this.toLocalSessionId(sessionResource)); - this._onDidDisposeSession.fire({ sessionResource: [sessionResource], reason: 'cleared' }); + this._onDidDisposeSession.fire({ sessionResources: [sessionResource], reason: 'cleared' }); } async clearAllHistoryEntries(): Promise { @@ -1845,39 +1833,17 @@ export class ChatService extends Disposable implements IChatService { } return localSessionId; } +} - public registerChatModelChangeListeners(chatSessionType: string, onChange: (chatSessionResource: URI) => void): IDisposable { - const disposableStore = new DisposableStore(); - const chatModelsICareAbout = this.chatModels.map(models => - Array.from(models).filter((model: IChatModel) => model.sessionResource.scheme === chatSessionType) - ); - - const listeners = new ResourceMap(); - const autoRunDisposable = autorunIterableDelta( - reader => chatModelsICareAbout.read(reader), - ({ addedValues, removedValues }) => { - removedValues.forEach((removed) => { - const listener = listeners.get(removed.sessionResource); - if (listener) { - listeners.delete(removed.sessionResource); - listener.dispose(); - } - }); - addedValues.forEach((added) => { - const requestChangeListener = added.lastRequestObs.map(last => last?.response && observableSignalFromEvent('chatSessions.modelRequestChangeListener', last.response.onDidChange)); - const modelChangeListener = observableSignalFromEvent('chatSessions.modelChangeListener', added.onDidChange); - listeners.set(added.sessionResource, autorun(reader => { - requestChangeListener.read(reader)?.read(reader); - modelChangeListener.read(reader); - onChange(added.sessionResource); - })); - }); - } - ); - disposableStore.add(toDisposable(() => { - for (const listener of listeners.values()) { listener.dispose(); } - })); - disposableStore.add(autoRunDisposable); - return disposableStore; - } +export async function chatModelToChatDetail(model: IChatModel): Promise { + const title = model.title || localize('newChat', "New Chat"); + return { + sessionResource: model.sessionResource, + title, + lastMessageDate: model.lastMessageDate, + timing: model.timing, + isActive: true, + stats: await awaitStatsForSession(model), + lastResponseState: model.lastRequest?.response?.state ?? ResponseModelState.Pending, + }; } diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 64f333769b515..eeba510b72b6e 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -55,6 +55,7 @@ export enum ChatConfiguration { ExplainChangesEnabled = 'chat.editing.explainChanges.enabled', RevealNextChangeOnResolve = 'chat.editing.revealNextChangeOnResolve', GrowthNotificationEnabled = 'chat.growthNotification.enabled', + SignInTitleBarEnabled = 'chat.signInTitleBar.enabled', ChatCustomizationMenuEnabled = 'chat.customizationsMenu.enabled', ChatCustomizationHarnessSelectorEnabled = 'chat.customizations.harnessSelector.enabled', AutopilotEnabled = 'chat.autopilot.enabled', diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index 9c5a935bc7815..228f9276ac1d3 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -137,6 +137,12 @@ export interface IExternalCustomizationItem { readonly type: string; readonly name: string; readonly description?: string; + /** When set, items with the same groupKey are displayed under a shared collapsible header. */ + readonly groupKey?: string; + /** When set, shows a small inline badge next to the item name (e.g. an applyTo glob pattern). */ + readonly badge?: string; + /** Tooltip shown when hovering the badge. */ + readonly badgeTooltip?: string; } /** @@ -433,7 +439,12 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer } private _getAllHarnesses(): readonly IHarnessDescriptor[] { - return [...this._staticHarnesses, ...this._externalHarnesses]; + // External harnesses override static ones with the same id + const externalIds = new Set(this._externalHarnesses.map(h => h.id)); + return [ + ...this._staticHarnesses.filter(h => !externalIds.has(h.id)), + ...this._externalHarnesses, + ]; } private _refreshAvailableHarnesses(): void { @@ -449,10 +460,11 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer if (idx >= 0) { this._externalHarnesses.splice(idx, 1); this._refreshAvailableHarnesses(); - // If the removed harness was active, fall back to the first available + // If the removed harness was active, only fall back when no + // remaining harness (e.g. a restored static one) shares the id. if (this._activeHarness.get() === descriptor.id) { const all = this._getAllHarnesses(); - if (all.length > 0) { + if (!all.some(h => h.id === descriptor.id) && all.length > 0) { this._activeHarness.set(all[0].id, undefined); } } diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 547db1b42f2f0..d418832b971fe 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -1377,6 +1377,7 @@ export interface IChatModel extends IDisposable { readonly sessionId: string; /** Milliseconds timestamp this chat model was created. */ readonly timestamp: number; + readonly lastMessageDate: number; readonly timing: IChatSessionTiming; readonly sessionResource: URI; readonly initialLocation: ChatAgentLocation; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 62b0430152f35..4e617edc4ae2c 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -175,13 +175,13 @@ export class PromptsService extends Disposable implements IPromptsService { @IInstantiationService protected readonly instantiationService: IInstantiationService, @IUserDataProfileService private readonly userDataService: IUserDataProfileService, @IConfigurationService private readonly configurationService: IConfigurationService, - @IFileService private readonly fileService: IFileService, + @IFileService protected readonly fileService: IFileService, @IFilesConfigurationService private readonly filesConfigService: IFilesConfigurationService, @IStorageService private readonly storageService: IStorageService, @IExtensionService private readonly extensionService: IExtensionService, @ITelemetryService private readonly telemetryService: ITelemetryService, @IWorkspaceContextService private readonly workspaceService: IWorkspaceContextService, - @IPathService private readonly pathService: IPathService, + @IPathService protected readonly pathService: IPathService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IAgentPluginService private readonly agentPluginService: IAgentPluginService, @IWorkspaceTrustManagementService private readonly workspaceTrustService: IWorkspaceTrustManagementService, diff --git a/src/vs/workbench/contrib/chat/common/widget/chatResponseResourceFileSystemProvider.ts b/src/vs/workbench/contrib/chat/common/widget/chatResponseResourceFileSystemProvider.ts index 6cab24df2d67e..acc15dcca1e56 100644 --- a/src/vs/workbench/contrib/chat/common/widget/chatResponseResourceFileSystemProvider.ts +++ b/src/vs/workbench/contrib/chat/common/widget/chatResponseResourceFileSystemProvider.ts @@ -61,7 +61,7 @@ export class ChatResponseResourceFileSystemProvider extends Disposable implement ) { super(); this._register(this.chatService.onDidDisposeSession(e => { - for (const sessionResource of e.sessionResource) { + for (const sessionResource of e.sessionResources) { const uris = this._sessionAssociations.get(sessionResource); if (uris) { for (const uri of uris) { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsController.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsController.test.ts index e8fc538290842..6111539540230 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsController.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsController.test.ts @@ -5,7 +5,6 @@ import assert from 'assert'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; -import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../../base/common/observable.js'; @@ -16,9 +15,10 @@ import { TestInstantiationService } from '../../../../../../platform/instantiati import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; import { LocalAgentsSessionsController } from '../../../browser/agentSessions/localAgentSessionsController.js'; import { IChatService, ResponseModelState } from '../../../common/chatService/chatService.js'; +import { chatModelToChatDetail } from '../../../common/chatService/chatServiceImpl.js'; import { ChatSessionStatus, IChatSessionItem, IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; -import { ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; -import { IChatModel, IChatRequestModel, IChatResponseModel } from '../../../common/model/chatModel.js'; +import { ChatEditingSessionState, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; +import { IChatChangedRequestEvent, IChatChangeEvent, IChatModel, IChatRequestModel, IChatResponseModel } from '../../../common/model/chatModel.js'; import { LocalChatSessionUri } from '../../../common/model/chatUri.js'; import { MockChatService } from '../../common/chatService/mockChatService.js'; import { MockChatSessionsService } from '../../common/mockChatSessionsService.js'; @@ -36,6 +36,11 @@ function createTestTiming(options?: { }; } +interface MockChatModel extends IChatModel { + setCustomTitle(title: string): void; + setRequestInProgress(inProgress: boolean): void; +} + function createMockChatModel(options: { sessionResource: URI; hasRequests?: boolean; @@ -55,7 +60,7 @@ function createMockChatModel(options: { modifiedURI: URI; }>; }; -}): IChatModel { +}): MockChatModel { const requests: IChatRequestModel[] = []; if (options.hasRequests !== false) { @@ -82,27 +87,46 @@ function createMockChatModel(options: { state: observableValue('state', entry.state), linesAdded: observableValue('linesAdded', entry.linesAdded), linesRemoved: observableValue('linesRemoved', entry.linesRemoved), - modifiedURI: entry.modifiedURI + originalURI: entry.modifiedURI, + modifiedURI: entry.modifiedURI, })); const mockEditingSession = options.editingSession ? { - entries: observableValue('entries', editingSessionEntries ?? []) + entries: observableValue('entries', editingSessionEntries ?? []), + state: observableValue('state', ChatEditingSessionState.Idle) } : undefined; - const _onDidChange = new Emitter<{ kind: string } | undefined>(); + const _onDidChange = new Emitter(); + let title = options.customTitle ?? 'Test Chat Title'; + const requestInProgress = observableValue('requestInProgress', options.requestInProgress ?? false); return { + get title() { + return title; + }, sessionResource: options.sessionResource, hasRequests: options.hasRequests !== false, timestamp: options.timestamp ?? Date.now(), - requestInProgress: observableValue('requestInProgress', options.requestInProgress ?? false), + timing: createTestTiming({ created: options.timestamp }), + requestInProgress, getRequests: () => requests, onDidChange: _onDidChange.event, - editingSession: mockEditingSession, - setCustomTitle: (_title: string) => { - _onDidChange.fire({ kind: 'setCustomTitle' }); - } - } as unknown as IChatModel; + editingSession: mockEditingSession as IChatModel['editingSession'], + lastRequestObs: observableValue('lastRequest', undefined), + + // Mock helpers + setCustomTitle: (newTitle: string) => { + title = newTitle; + _onDidChange.fire({ kind: 'setCustomTitle', title }); + }, + setRequestInProgress: (inProgress: boolean) => { + if (requestInProgress.get() === inProgress) { + return; + } + requestInProgress.set(inProgress, undefined); + _onDidChange.fire({ kind: 'changedRequest' } as IChatChangedRequestEvent); + }, + } as Partial as MockChatModel; } suite('LocalAgentsSessionsController', () => { @@ -542,35 +566,6 @@ suite('LocalAgentsSessionsController', () => { }); }); - suite('Session Icon', () => { - test('should use Codicon.chatSparkle as icon', async () => { - return runWithFakedTimers({}, async () => { - const controller = createController(); - - const sessionResource = LocalChatSessionUri.forSession('icon-session'); - const mockModel = createMockChatModel({ - sessionResource, - hasRequests: true - }); - - mockChatService.addSession(mockModel); - mockChatService.setLiveSessionItems([{ - sessionResource, - title: 'Icon Session', - lastMessageDate: Date.now(), - isActive: true, - lastResponseState: ResponseModelState.Complete, - timing: createTestTiming() - }]); - - await controller.refresh(CancellationToken.None); - const sessions = controller.items; - assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].iconPath, Codicon.chatSparkle); - }); - }); - }); - suite('Events', () => { test('should fire onDidChangeChatSessionItems when model progress changes', async () => { return runWithFakedTimers({}, async () => { @@ -599,19 +594,21 @@ suite('LocalAgentsSessionsController', () => { changeEventCount++; })); + await controller.refresh(CancellationToken.None); + const onDidChangeChatSessionItems = Event.toPromise(controller.onDidChangeChatSessionItems); // Simulate progress change by triggering the progress listener - mockChatService.triggerProgressEvent(sessionResource); + mockModel.setRequestInProgress(true); await onDidChangeChatSessionItems; - assert.strictEqual(changeEventCount, 1); + assert.strictEqual(changeEventCount, 2); }); }); test('should fire onDidChangeChatSessionItems when model request status changes', async () => { return runWithFakedTimers({}, async () => { - const controller = createController(); + const controller = disposables.add(createController()); const sessionResource = LocalChatSessionUri.forSession('status-change-session'); const mockModel = createMockChatModel({ @@ -622,24 +619,18 @@ suite('LocalAgentsSessionsController', () => { // Add the session first mockChatService.addSession(mockModel); - mockChatService.setLiveSessionItems([{ - sessionResource, - title: 'Test Session', - lastMessageDate: Date.now(), - isActive: true, - timing: createTestTiming(), - lastResponseState: ResponseModelState.Complete - }]); + mockChatService.setLiveSessionItems([await chatModelToChatDetail(mockModel)]); let changeEventCount = 0; disposables.add(controller.onDidChangeChatSessionItems(() => { changeEventCount++; })); + await controller.refresh(CancellationToken.None); + assert.strictEqual(changeEventCount, 0); const onDidChangeChatSessionItems = Event.toPromise(controller.onDidChangeChatSessionItems); - // Simulate progress change by triggering the progress listener - mockChatService.triggerProgressEvent(sessionResource); + mockModel.setRequestInProgress(true); await onDidChangeChatSessionItems; assert.strictEqual(changeEventCount, 1); @@ -670,7 +661,7 @@ suite('LocalAgentsSessionsController', () => { changeEventCount++; })); - (mockModel as unknown as { setCustomTitle: (title: string) => void }).setCustomTitle('New Title'); + mockModel.setCustomTitle('New Title'); assert.strictEqual(changeEventCount, 0, 'onDidChangeChatSessionItems should NOT fire after model is removed'); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts index 6362d8764f2ce..4ae53b7106346 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts @@ -366,9 +366,10 @@ suite('ChatQuestionCarouselPart', () => { ]); createWidget(carousel); + // Default option 'b' is re-sorted to appear first const listItems = widget.domNode.querySelectorAll('.chat-question-list-item') as NodeListOf; - assert.strictEqual(listItems[0].classList.contains('selected'), false); - assert.strictEqual(listItems[1].classList.contains('selected'), true, 'Default option should be selected'); + assert.strictEqual(listItems[0].classList.contains('selected'), true, 'Default option should be re-sorted to first and selected'); + assert.strictEqual(listItems[1].classList.contains('selected'), false); }); test('default options are pre-selected for multiSelect', () => { @@ -387,10 +388,67 @@ suite('ChatQuestionCarouselPart', () => { ]); createWidget(carousel); + // Default options 'a' and 'c' are re-sorted to appear first const listItems = widget.domNode.querySelectorAll('.chat-question-list-item') as NodeListOf; assert.strictEqual(listItems[0].classList.contains('checked'), true, 'First default option should be checked'); - assert.strictEqual(listItems[1].classList.contains('checked'), false); - assert.strictEqual(listItems[2].classList.contains('checked'), true, 'Third default option should be checked'); + assert.strictEqual(listItems[1].classList.contains('checked'), true, 'Second default option should be checked (re-sorted from third)'); + assert.strictEqual(listItems[2].classList.contains('checked'), false, 'Non-default option should not be checked'); + }); + + test('singleSelect keeps value mapping after default-first reordering', () => { + const carousel = createMockCarousel([ + { + id: 'q1', + type: 'singleSelect', + title: 'Choose one', + options: [ + { id: 'a', label: 'Option A', value: 'value_a' }, + { id: 'b', label: 'Option B', value: 'value_b' } + ], + defaultValue: 'b' + } + ]); + createWidget(carousel); + + const listItems = widget.domNode.querySelectorAll('.chat-question-list-item') as NodeListOf; + assert.strictEqual(listItems.length, 2, 'Expected two options'); + listItems[1].click(); // Option A after default-first ordering + + const answer = submittedAnswers?.get('q1') as { selectedValue: unknown; freeformValue: unknown }; + assert.strictEqual(answer.selectedValue, 'value_a'); + assert.strictEqual(answer.freeformValue, undefined); + }); + + test('multiSelect keeps value mapping after default-first reordering', () => { + const carousel = createMockCarousel([ + { + id: 'q1', + type: 'multiSelect', + title: 'Choose multiple', + options: [ + { id: 'a', label: 'Option A', value: 'value_a' }, + { id: 'b', label: 'Option B', value: 'value_b' }, + { id: 'c', label: 'Option C', value: 'value_c' } + ], + defaultValue: 'c' + } + ]); + createWidget(carousel); + + const listItems = widget.domNode.querySelectorAll('.chat-question-list-item') as NodeListOf; + assert.strictEqual(listItems.length, 3, 'Expected three options'); + listItems[1].click(); // Option A after default-first ordering + + const submitButton = widget.domNode.querySelector('.chat-question-submit-button') as HTMLButtonElement; + assert.ok(submitButton, 'Submit button should exist'); + submitButton.click(); + + const answer = submittedAnswers?.get('q1') as { selectedValues: unknown[]; freeformValue: unknown }; + assert.ok(Array.isArray(answer.selectedValues)); + assert.ok(answer.selectedValues.includes('value_a')); + assert.ok(answer.selectedValues.includes('value_c')); + assert.strictEqual(answer.selectedValues.length, 2); + assert.strictEqual(answer.freeformValue, undefined); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCompletions.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCompletions.test.ts new file mode 100644 index 0000000000000..686fee9beb961 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCompletions.test.ts @@ -0,0 +1,275 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../../base/test/common/utils.js'; +import { Position } from '../../../../../../../../editor/common/core/position.js'; +import { Range } from '../../../../../../../../editor/common/core/range.js'; +import { createTextModel } from '../../../../../../../../editor/test/common/testTextModel.js'; +import { DisposableStore } from '../../../../../../../../base/common/lifecycle.js'; +import { computeCompletionRanges, escapeForCharClass } from '../../../../../browser/widget/input/editor/chatInputCompletionUtils.js'; +import { chatAgentLeader, chatVariableLeader } from '../../../../../common/requestParser/chatParserTypes.js'; + +suite('escapeForCharClass', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('passes through simple characters unchanged', () => { + assert.strictEqual(escapeForCharClass('a'), 'a'); + assert.strictEqual(escapeForCharClass('#'), '#'); + assert.strictEqual(escapeForCharClass('@'), '@'); + }); + + test('escapes backslash', () => { + assert.strictEqual(escapeForCharClass('\\'), '\\\\'); + }); + + test('escapes closing bracket', () => { + assert.strictEqual(escapeForCharClass(']'), '\\]'); + }); + + test('escapes caret', () => { + assert.strictEqual(escapeForCharClass('^'), '\\^'); + }); + + test('escapes hyphen', () => { + assert.strictEqual(escapeForCharClass('-'), '\\-'); + }); + + test('escapes multiple special chars in one string', () => { + assert.strictEqual(escapeForCharClass('-^]\\'), '\\-\\^\\]\\\\'); + }); + + test('is safe to use for chatVariableLeader and chatAgentLeader', () => { + // These are the actual values used in the product code + const escaped = `[${escapeForCharClass(chatVariableLeader)}${escapeForCharClass(chatAgentLeader)}]`; + const re = new RegExp(escaped); + assert.ok(re.test('#')); + assert.ok(re.test('@')); + assert.ok(!re.test('a')); + assert.ok(!re.test('/')); + }); +}); + +suite('computeCompletionRanges', () => { + + let store: DisposableStore; + + setup(() => { + store = new DisposableStore(); + }); + + teardown(() => { + store.dispose(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + // Helper: builds the same regex patterns used in the product code + function variableNameDef() { + return new RegExp(`[${escapeForCharClass(chatVariableLeader)}${escapeForCharClass(chatAgentLeader)}][\\w:-]*`, 'g'); + } + + function fileWordPattern() { + return new RegExp(`[${escapeForCharClass(chatVariableLeader)}${escapeForCharClass(chatAgentLeader)}][^\\s]*`, 'g'); + } + + function toolVariableNameDef() { + return new RegExp(`(?<=^|\\s)[${escapeForCharClass(chatVariableLeader)}${escapeForCharClass(chatAgentLeader)}]\\w*`, 'g'); + } + + // --- VariableNameDef pattern tests --- + + suite('with VariableNameDef regex', () => { + + test('matches #variable at start of line', () => { + const model = store.add(createTextModel('#file', null, undefined, URI.parse('test:input'))); + const result = computeCompletionRanges(model, new Position(1, 6), variableNameDef()); + assert.ok(result); + assert.deepStrictEqual(result, { + insert: new Range(1, 1, 1, 6), + replace: new Range(1, 1, 1, 6), + varWord: { word: '#file', startColumn: 1, endColumn: 6 }, + }); + }); + + test('matches @variable at start of line', () => { + const model = store.add(createTextModel('@file', null, undefined, URI.parse('test:input'))); + const result = computeCompletionRanges(model, new Position(1, 6), variableNameDef()); + assert.ok(result); + assert.deepStrictEqual(result, { + insert: new Range(1, 1, 1, 6), + replace: new Range(1, 1, 1, 6), + varWord: { word: '@file', startColumn: 1, endColumn: 6 }, + }); + }); + + test('matches #variable mid-line after space', () => { + const model = store.add(createTextModel('hello #file', null, undefined, URI.parse('test:input'))); + const result = computeCompletionRanges(model, new Position(1, 12), variableNameDef()); + assert.ok(result); + assert.deepStrictEqual(result, { + insert: new Range(1, 7, 1, 12), + replace: new Range(1, 7, 1, 12), + varWord: { word: '#file', startColumn: 7, endColumn: 12 }, + }); + }); + + test('matches @variable mid-line after space', () => { + const model = store.add(createTextModel('hello @file', null, undefined, URI.parse('test:input'))); + const result = computeCompletionRanges(model, new Position(1, 12), variableNameDef()); + assert.ok(result); + assert.deepStrictEqual(result, { + insert: new Range(1, 7, 1, 12), + replace: new Range(1, 7, 1, 12), + varWord: { word: '@file', startColumn: 7, endColumn: 12 }, + }); + }); + + test('matches # alone (just the leader)', () => { + const model = store.add(createTextModel('#', null, undefined, URI.parse('test:input'))); + const result = computeCompletionRanges(model, new Position(1, 2), variableNameDef()); + assert.ok(result); + assert.strictEqual(result.varWord?.word, '#'); + }); + + test('matches @ alone (just the leader)', () => { + const model = store.add(createTextModel('@', null, undefined, URI.parse('test:input'))); + const result = computeCompletionRanges(model, new Position(1, 2), variableNameDef()); + assert.ok(result); + assert.strictEqual(result.varWord?.word, '@'); + }); + + test('matches variable with colons and hyphens', () => { + const model = store.add(createTextModel('#file:test-1', null, undefined, URI.parse('test:input'))); + const result = computeCompletionRanges(model, new Position(1, 13), variableNameDef()); + assert.ok(result); + assert.strictEqual(result.varWord?.word, '#file:test-1'); + }); + + test('cursor in middle of variable produces partial insert range', () => { + const model = store.add(createTextModel('@selection', null, undefined, URI.parse('test:input'))); + const result = computeCompletionRanges(model, new Position(1, 5), variableNameDef()); + assert.ok(result); + assert.deepStrictEqual(result, { + insert: new Range(1, 1, 1, 5), + replace: new Range(1, 1, 1, 11), + varWord: { word: '@selection', startColumn: 1, endColumn: 11 }, + }); + }); + }); + + // --- fileWordPattern tests --- + + suite('with fileWordPattern regex', () => { + + test('matches #file:path/to/file.ts', () => { + const model = store.add(createTextModel('#file:path/to/file.ts', null, undefined, URI.parse('test:input'))); + const result = computeCompletionRanges(model, new Position(1, 22), fileWordPattern()); + assert.ok(result); + assert.strictEqual(result.varWord?.word, '#file:path/to/file.ts'); + }); + + test('matches @file:path/to/file.ts', () => { + const model = store.add(createTextModel('@file:path/to/file.ts', null, undefined, URI.parse('test:input'))); + const result = computeCompletionRanges(model, new Position(1, 22), fileWordPattern()); + assert.ok(result); + assert.strictEqual(result.varWord?.word, '@file:path/to/file.ts'); + }); + + test('stops at whitespace', () => { + const model = store.add(createTextModel('#file:test rest', null, undefined, URI.parse('test:input'))); + const result = computeCompletionRanges(model, new Position(1, 11), fileWordPattern()); + assert.ok(result); + assert.strictEqual(result.varWord?.word, '#file:test'); + }); + }); + + // --- toolVariableNameDef tests --- + + suite('with toolVariableNameDef regex', () => { + + test('matches #tool at start of line', () => { + const model = store.add(createTextModel('#tool', null, undefined, URI.parse('test:input'))); + const result = computeCompletionRanges(model, new Position(1, 6), toolVariableNameDef()); + assert.ok(result); + assert.strictEqual(result.varWord?.word, '#tool'); + }); + + test('matches @tool at start of line', () => { + const model = store.add(createTextModel('@tool', null, undefined, URI.parse('test:input'))); + const result = computeCompletionRanges(model, new Position(1, 6), toolVariableNameDef()); + assert.ok(result); + assert.strictEqual(result.varWord?.word, '@tool'); + }); + + test('matches #tool after space', () => { + const model = store.add(createTextModel('use #fetch', null, undefined, URI.parse('test:input'))); + const result = computeCompletionRanges(model, new Position(1, 11), toolVariableNameDef()); + assert.ok(result); + assert.strictEqual(result.varWord?.word, '#fetch'); + }); + + test('matches @tool after space', () => { + const model = store.add(createTextModel('use @fetch', null, undefined, URI.parse('test:input'))); + const result = computeCompletionRanges(model, new Position(1, 11), toolVariableNameDef()); + assert.ok(result); + assert.strictEqual(result.varWord?.word, '@fetch'); + }); + }); + + // --- Edge cases --- + + suite('edge cases', () => { + + test('returns undefined inside a normal word', () => { + const model = store.add(createTextModel('hello', null, undefined, URI.parse('test:input'))); + const result = computeCompletionRanges(model, new Position(1, 3), variableNameDef()); + assert.strictEqual(result, undefined); + }); + + test('returns undefined when no space before cursor mid-line', () => { + const model = store.add(createTextModel('ab', null, undefined, URI.parse('test:input'))); + const result = computeCompletionRanges(model, new Position(1, 3), variableNameDef()); + assert.strictEqual(result, undefined); + }); + + test('returns empty range at blank position after space', () => { + const model = store.add(createTextModel('hello ', null, undefined, URI.parse('test:input'))); + const result = computeCompletionRanges(model, new Position(1, 7), variableNameDef()); + assert.ok(result); + assert.strictEqual(result.varWord, null); + assert.deepStrictEqual(result.insert, Range.fromPositions(new Position(1, 7))); + }); + + test('returns empty range at start of empty line', () => { + const model = store.add(createTextModel('', null, undefined, URI.parse('test:input'))); + const result = computeCompletionRanges(model, new Position(1, 1), variableNameDef()); + assert.ok(result); + assert.strictEqual(result.varWord, null); + }); + + test('onlyOnWordStart=true rejects variable preceded by a word', () => { + const model = store.add(createTextModel('abc#file', null, undefined, URI.parse('test:input'))); + const result = computeCompletionRanges(model, new Position(1, 9), variableNameDef(), true); + assert.strictEqual(result, undefined); + }); + + test('onlyOnWordStart=true accepts variable after space', () => { + const model = store.add(createTextModel('abc #file', null, undefined, URI.parse('test:input'))); + const result = computeCompletionRanges(model, new Position(1, 10), variableNameDef(), true); + assert.ok(result); + assert.strictEqual(result.varWord?.word, '#file'); + }); + + test('onlyOnWordStart=true accepts @variable after space', () => { + const model = store.add(createTextModel('abc @file', null, undefined, URI.parse('test:input'))); + const result = computeCompletionRanges(model, new Position(1, 10), variableNameDef(), true); + assert.ok(result); + assert.strictEqual(result.varWord?.word, '@file'); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 89a0d4d6d2465..afa76442175a2 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -426,7 +426,7 @@ suite('ChatService', () => { let disposed = false; testDisposables.add(testService.onDidDisposeSession(e => { - for (const resource of e.sessionResource) { + for (const resource of e.sessionResources) { if (resource.toString() === model.sessionResource.toString()) { disposed = true; } 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 52c097fac48f8..07e106b2c43ca 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts @@ -5,7 +5,6 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; -import { IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; import { ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -21,17 +20,19 @@ export class MockChatService implements IChatService { editingSessions = []; transferredSessionResource = undefined; readonly onDidSubmitRequest = Event.None; - readonly onDidCreateModel = Event.None; + + private readonly _onDidCreateModel = new Emitter(); + readonly onDidCreateModel = this._onDidCreateModel.event; private readonly sessions = new ResourceMap(); private liveSessionItems: IChatDetail[] = []; private historySessionItems: IChatDetail[] = []; - private readonly _onDidDisposeSession = new Emitter<{ sessionResource: URI[]; reason: 'cleared' }>(); + private readonly _onDidDisposeSession = new Emitter<{ sessionResources: URI[]; reason: 'cleared' }>(); readonly onDidDisposeSession = this._onDidDisposeSession.event; - fireDidDisposeSession(sessionResource: URI[]): void { - this._onDidDisposeSession.fire({ sessionResource, reason: 'cleared' }); + fireDidDisposeSession(sessionResources: URI[]): void { + this._onDidDisposeSession.fire({ sessionResources, reason: 'cleared' }); } setSaveModelsEnabled(enabled: boolean): void { @@ -54,6 +55,7 @@ export class MockChatService implements IChatService { this.sessions.set(session.sessionResource, session); // Update the chatModels observable this._chatModels.set([...this.sessions.values()], undefined); + this._onDidCreateModel.fire(session); } removeSession(sessionResource: URI): void { @@ -191,22 +193,4 @@ export class MockChatService implements IChatService { getMetadataForSession(sessionResource: URI): Promise { throw new Error('Method not implemented.'); } - - - private onChange?: (sessionResource: URI) => void; - - registerChatModelChangeListeners(chatSessionType: string, onChange: (sessionResource: URI) => void): IDisposable { - // Store the emitter so tests can trigger it - this.onChange = onChange; - return { - dispose: () => { - this.onChange = undefined; - } - }; - } - - // Helper method for tests to trigger progress events - triggerProgressEvent(sessionResource: URI): void { - this.onChange?.(sessionResource); - } } diff --git a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts index e18d4cf275009..350b43e7a3509 100644 --- a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts @@ -193,6 +193,110 @@ suite('CustomizationHarnessService', () => { assert.deepStrictEqual(descriptor.hiddenSections, ['agents', 'prompts']); assert.deepStrictEqual(descriptor.workspaceSubpaths, ['.test-ext']); }); + + test('external harness with same id as static harness replaces it', () => { + const staticDescriptor: IHarnessDescriptor = { + id: 'cli', + label: 'Copilot CLI', + icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + }; + const service = createService( + createVSCodeHarnessDescriptor([PromptsStorage.extension]), + staticDescriptor, + ); + assert.strictEqual(service.availableHarnesses.get().length, 2); + + const emitter = new Emitter(); + store.add(emitter); + const externalDescriptor: IHarnessDescriptor = { + id: 'cli', + label: 'Copilot CLI (from API)', + icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + itemProvider: { + onDidChange: emitter.event, + provideChatSessionCustomizations: async () => [], + }, + }; + + const reg = service.registerExternalHarness(externalDescriptor); + store.add(reg); + + // Should still be 2, not 3 — the external replaces the static + assert.strictEqual(service.availableHarnesses.get().length, 2); + const cliHarness = service.availableHarnesses.get().find(h => h.id === 'cli')!; + assert.strictEqual(cliHarness.label, 'Copilot CLI (from API)'); + }); + + test('static harness reappears when replacing external harness is disposed', () => { + const staticDescriptor: IHarnessDescriptor = { + id: 'cli', + label: 'Copilot CLI', + icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + }; + const service = createService( + createVSCodeHarnessDescriptor([PromptsStorage.extension]), + staticDescriptor, + ); + + const emitter = new Emitter(); + store.add(emitter); + const externalDescriptor: IHarnessDescriptor = { + id: 'cli', + label: 'Copilot CLI (from API)', + icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + itemProvider: { + onDidChange: emitter.event, + provideChatSessionCustomizations: async () => [], + }, + }; + + const reg = service.registerExternalHarness(externalDescriptor); + reg.dispose(); + + // Static harness should be back + assert.strictEqual(service.availableHarnesses.get().length, 2); + const cliHarness = service.availableHarnesses.get().find(h => h.id === 'cli')!; + assert.strictEqual(cliHarness.label, 'Copilot CLI'); + }); + + test('active harness stays when overriding external harness is disposed', () => { + const staticDescriptor: IHarnessDescriptor = { + id: 'cli', + label: 'Copilot CLI', + icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + }; + const service = createService( + createVSCodeHarnessDescriptor([PromptsStorage.extension]), + staticDescriptor, + ); + + const emitter = new Emitter(); + store.add(emitter); + const externalDescriptor: IHarnessDescriptor = { + id: 'cli', + label: 'Copilot CLI (from API)', + icon: ThemeIcon.fromId('extensions'), + getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }), + itemProvider: { + onDidChange: emitter.event, + provideChatSessionCustomizations: async () => [], + }, + }; + + const reg = service.registerExternalHarness(externalDescriptor); + service.setActiveHarness('cli'); + assert.strictEqual(service.activeHarness.get(), 'cli'); + + reg.dispose(); + + // Active harness should stay on 'cli' — the static one is restored + assert.strictEqual(service.activeHarness.get(), 'cli'); + }); }); suite('matchesWorkspaceSubpath', () => { diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryServiceImpl.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryServiceImpl.ts index 9e753e0ed6fbb..2b620f1c4f208 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryServiceImpl.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryServiceImpl.ts @@ -6,7 +6,6 @@ import { EditSuggestionId } from '../../../../../../editor/common/textModelEditSource.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; -import { TelemetryTrustedValue } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; import { DataChannelForwardingTelemetryService, forwardToChannelIf, isCopilotLikeExtension } from '../../../../../../platform/dataChannel/browser/forwardingTelemetryService.js'; import { IAiEditTelemetryService, IEditTelemetryCodeAcceptedData, IEditTelemetryCodeSuggestedData } from './aiEditTelemetryService.js'; import { IRandomService } from '../../randomService.js'; @@ -43,7 +42,7 @@ export class AiEditTelemetryServiceImpl implements IAiEditTelemetryService { editLinesDeleted: number | undefined; modeId: 'ask' | 'edit' | 'agent' | 'custom' | 'applyCodeBlock' | undefined; - modelId: TelemetryTrustedValue; + modelId: string | undefined; applyCodeBlockSuggestionId: string | undefined; }, { owner: 'hediet'; @@ -85,7 +84,7 @@ export class AiEditTelemetryServiceImpl implements IAiEditTelemetryService { editLinesDeleted: data.editDeltaInfo?.linesRemoved, modeId: data.modeId, - modelId: new TelemetryTrustedValue(data.modelId), + modelId: data.modelId?.replace(/[\/\\]/g, '_'), applyCodeBlockSuggestionId: data.applyCodeBlockSuggestionId as unknown as string, ...forwardToChannelIf(isCopilotLikeExtension(data.source?.extensionId)), @@ -114,7 +113,7 @@ export class AiEditTelemetryServiceImpl implements IAiEditTelemetryService { editLinesDeleted: number | undefined; modeId: 'ask' | 'edit' | 'agent' | 'custom' | 'applyCodeBlock' | undefined; - modelId: TelemetryTrustedValue; + modelId: string | undefined; applyCodeBlockSuggestionId: string | undefined; acceptanceMethod: @@ -166,7 +165,7 @@ export class AiEditTelemetryServiceImpl implements IAiEditTelemetryService { editLinesDeleted: data.editDeltaInfo?.linesRemoved, modeId: data.modeId, - modelId: new TelemetryTrustedValue(data.modelId), + modelId: data.modelId?.replace(/[\/\\]/g, '_'), applyCodeBlockSuggestionId: data.applyCodeBlockSuggestionId as unknown as string, acceptanceMethod: data.acceptanceMethod, diff --git a/src/vs/workbench/contrib/editTelemetry/test/browser/editTelemetry.test.ts b/src/vs/workbench/contrib/editTelemetry/test/browser/editTelemetry.test.ts index 5ac15f260deb4..ea95c818a8a34 100644 --- a/src/vs/workbench/contrib/editTelemetry/test/browser/editTelemetry.test.ts +++ b/src/vs/workbench/contrib/editTelemetry/test/browser/editTelemetry.test.ts @@ -106,9 +106,9 @@ function fib(n) { assert.deepStrictEqual(sentTelemetry, ([ '00:01:010 editTelemetry.reportEditArc: {\"sourceKeyCleaned\":\"source:Chat.applyEdits\",\"languageId\":\"plaintext\",\"uniqueEditId\":\"8c97b7d8-9adb-4bd8-ac9f-a562704ce40e\",\"didBranchChange\":0,\"timeDelayMs\":0,\"originalCharCount\":37,\"originalLineCount\":1,\"originalDeletedLineCount\":0,\"arc\":37,\"currentLineCount\":1,\"currentDeletedLineCount\":0}', - '00:01:010 editTelemetry.codeSuggested: {\"eventId\":\"evt-055ed5f5-c723-4ede-ba79-cccd7685c7ad\",\"suggestionId\":\"sgt-f645627a-cacf-477a-9164-ecd6125616a5\",\"presentation\":\"highlightedEdit\",\"feature\":\"sideBarChat\",\"languageId\":\"plaintext\",\"editCharsInserted\":37,\"editCharsDeleted\":0,\"editLinesInserted\":1,\"editLinesDeleted\":0,\"modelId\":{\"isTrustedTelemetryValue\":true}}', + '00:01:010 editTelemetry.codeSuggested: {\"eventId\":\"evt-055ed5f5-c723-4ede-ba79-cccd7685c7ad\",\"suggestionId\":\"sgt-f645627a-cacf-477a-9164-ecd6125616a5\",\"presentation\":\"highlightedEdit\",\"feature\":\"sideBarChat\",\"languageId\":\"plaintext\",\"editCharsInserted\":37,\"editCharsDeleted\":0,\"editLinesInserted\":1,\"editLinesDeleted\":0}', '00:11:010 editTelemetry.reportEditArc: {\"sourceKeyCleaned\":\"source:Chat.applyEdits\",\"languageId\":\"plaintext\",\"uniqueEditId\":\"1eb8a394-2489-41c2-851b-6a79432fc6bc\",\"didBranchChange\":0,\"timeDelayMs\":0,\"originalCharCount\":19,\"originalLineCount\":1,\"originalDeletedLineCount\":1,\"arc\":19,\"currentLineCount\":1,\"currentDeletedLineCount\":1}', - '00:11:010 editTelemetry.codeSuggested: {\"eventId\":\"evt-5c9c6fe7-b219-4ff8-aaa7-ab2b355b21c0\",\"suggestionId\":\"sgt-74379122-0452-4e26-9c38-9d62f1e7ae73\",\"presentation\":\"highlightedEdit\",\"feature\":\"sideBarChat\",\"languageId\":\"plaintext\",\"editCharsInserted\":19,\"editCharsDeleted\":20,\"editLinesInserted\":1,\"editLinesDeleted\":1,\"modelId\":{\"isTrustedTelemetryValue\":true}}', + '00:11:010 editTelemetry.codeSuggested: {\"eventId\":\"evt-5c9c6fe7-b219-4ff8-aaa7-ab2b355b21c0\",\"suggestionId\":\"sgt-74379122-0452-4e26-9c38-9d62f1e7ae73\",\"presentation\":\"highlightedEdit\",\"feature\":\"sideBarChat\",\"languageId\":\"plaintext\",\"editCharsInserted\":19,\"editCharsDeleted\":20,\"editLinesInserted\":1,\"editLinesDeleted\":1}', '01:01:010 editTelemetry.reportEditArc: {\"sourceKeyCleaned\":\"source:Chat.applyEdits\",\"languageId\":\"plaintext\",\"uniqueEditId\":\"8c97b7d8-9adb-4bd8-ac9f-a562704ce40e\",\"didBranchChange\":0,\"timeDelayMs\":60000,\"originalCharCount\":37,\"originalLineCount\":1,\"originalDeletedLineCount\":0,\"arc\":16,\"currentLineCount\":1,\"currentDeletedLineCount\":0}', '01:11:010 editTelemetry.reportEditArc: {\"sourceKeyCleaned\":\"source:Chat.applyEdits\",\"languageId\":\"plaintext\",\"uniqueEditId\":\"1eb8a394-2489-41c2-851b-6a79432fc6bc\",\"didBranchChange\":0,\"timeDelayMs\":60000,\"originalCharCount\":19,\"originalLineCount\":1,\"originalDeletedLineCount\":1,\"arc\":19,\"currentLineCount\":1,\"currentDeletedLineCount\":1}', '05:01:010 editTelemetry.reportEditArc: {\"sourceKeyCleaned\":\"source:Chat.applyEdits\",\"languageId\":\"plaintext\",\"uniqueEditId\":\"8c97b7d8-9adb-4bd8-ac9f-a562704ce40e\",\"didBranchChange\":0,\"timeDelayMs\":300000,\"originalCharCount\":37,\"originalLineCount\":1,\"originalDeletedLineCount\":0,\"arc\":16,\"currentLineCount\":1,\"currentDeletedLineCount\":0}', diff --git a/src/vs/workbench/contrib/notebook/browser/controller/chat/notebook.chat.contribution.ts b/src/vs/workbench/contrib/notebook/browser/controller/chat/notebook.chat.contribution.ts index 251cf6b75213f..4ca8a0ff9e097 100644 --- a/src/vs/workbench/contrib/notebook/browser/controller/chat/notebook.chat.contribution.ts +++ b/src/vs/workbench/contrib/notebook/browser/controller/chat/notebook.chat.contribution.ts @@ -24,7 +24,7 @@ import { IEditorService } from '../../../../../services/editor/common/editorServ import { IChatWidget, IChatWidgetService } from '../../../../chat/browser/chat.js'; import { IChatContextPicker, IChatContextPickerItem, IChatContextPickerPickItem, IChatContextPickService } from '../../../../chat/browser/attachments/chatContextPickService.js'; import { ChatDynamicVariableModel } from '../../../../chat/browser/attachments/chatDynamicVariables.js'; -import { computeCompletionRanges } from '../../../../chat/browser/widget/input/editor/chatInputCompletions.js'; +import { computeCompletionRanges } from '../../../../chat/browser/widget/input/editor/chatInputCompletionUtils.js'; import { IChatAgentService } from '../../../../chat/common/participants/chatAgents.js'; import { ChatContextKeys } from '../../../../chat/common/actions/chatContextKeys.js'; import { chatVariableLeader } from '../../../../chat/common/requestParser/chatParserTypes.js'; diff --git a/src/vs/workbench/contrib/terminal/browser/media/terminal.css b/src/vs/workbench/contrib/terminal/browser/media/terminal.css index ce339d20d7f76..9370b48b871f6 100644 --- a/src/vs/workbench/contrib/terminal/browser/media/terminal.css +++ b/src/vs/workbench/contrib/terminal/browser/media/terminal.css @@ -62,8 +62,8 @@ background-image: none !important; } -.monaco-workbench .terminal-editor .terminal-wrapper.use-editor-background { - background-color: var(--vscode-editor-background); +.monaco-workbench .terminal-editor .terminal-wrapper { + background-color: var(--vscode-terminal-background, var(--vscode-editorPane-background)); } .monaco-workbench .terminal-editor .terminal-wrapper, .monaco-workbench .pane-body.integrated-terminal .terminal-wrapper { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index 169510b62c55b..75894ea61eb4e 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -227,7 +227,6 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { set target(value: TerminalLocation | undefined) { this._targetRef.object = value; this._onDidChangeTarget.fire(value); - this._updateEditorBackgroundClass(); } get instanceId(): number { return this._instanceId; } @@ -400,7 +399,6 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { this._wrapperElement = document.createElement('div'); this._wrapperElement.classList.add('terminal-wrapper'); - this._updateEditorBackgroundClass(); this._widgetManager = this._register(instantiationService.createInstance(TerminalWidgetManager)); @@ -583,9 +581,6 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { this.updateConfig(); this.setVisible(this._isVisible); } - if (e.affectsConfiguration(TerminalSettingId.EditorUseEditorBackground)) { - this._updateEditorBackgroundClass(); - } const layoutSettings: string[] = [ TerminalSettingId.FontSize, TerminalSettingId.FontFamily, @@ -1923,11 +1918,6 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { this._refreshEnvironmentVariableInfoWidgetState(this._processManager.environmentVariableInfo); } - private _updateEditorBackgroundClass(): void { - const useEditorBg = this.target === TerminalLocation.Editor && this._configurationService.getValue(TerminalSettingId.EditorUseEditorBackground); - this._wrapperElement.classList.toggle('use-editor-background', !!useEditorBg); - } - private async _updateUnicodeVersion(): Promise { this._processManager.setUnicodeVersion(this._terminalConfigurationService.config.unicodeVersion); } @@ -2823,14 +2813,10 @@ export class TerminalInstanceColorProvider implements IXtermColorProvider { constructor( private readonly _target: IReference, @IViewDescriptorService private readonly _viewDescriptorService: IViewDescriptorService, - @IConfigurationService private readonly _configurationService: IConfigurationService, ) { } getBackgroundColor(theme: IColorTheme) { - if (this._target.object === TerminalLocation.Editor && this._configurationService.getValue(TerminalSettingId.EditorUseEditorBackground)) { - return theme.getColor(editorBackground); - } const terminalBackground = theme.getColor(TERMINAL_BACKGROUND_COLOR); if (terminalBackground) { return terminalBackground; diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index 84c101a6b93ca..b043728acf3dc 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -281,7 +281,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach if (e.affectsConfiguration(TerminalSettingId.UnicodeVersion)) { this._updateUnicodeVersion(); } - if (e.affectsConfiguration(TerminalSettingId.ShellIntegrationDecorationsEnabled) || e.affectsConfiguration(TerminalSettingId.EditorUseEditorBackground)) { + if (e.affectsConfiguration(TerminalSettingId.ShellIntegrationDecorationsEnabled)) { this._updateTheme(); } })); diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index e539589cb1fd3..a3453dbac8fb3 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts @@ -124,11 +124,6 @@ const terminalConfiguration: IStringDictionary = { default: 'view', description: localize('terminal.integrated.defaultLocation', "Controls where newly created terminals will appear.") }, - [TerminalSettingId.EditorUseEditorBackground]: { - type: 'boolean', - default: true, - markdownDescription: localize('terminal.integrated.editorUseEditorBackground', "Controls whether terminals in the editor area use the editor background color instead of the terminal background color. When enabled, this takes precedence over {0} for terminals in the editor area.", '`#terminal.integrated.background#`') - }, [TerminalSettingId.TabsFocusMode]: { type: 'string', enum: ['singleClick', 'doubleClick'], diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts index 834904a62033c..9f6b3e3cfbac0 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts @@ -4,9 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { deepStrictEqual, strictEqual } from 'assert'; -import { Color } from '../../../../../base/common/color.js'; import { Event } from '../../../../../base/common/event.js'; -import { Disposable, ImmortalReference } from '../../../../../base/common/lifecycle.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; import { isWindows, type IProcessEnvironment } from '../../../../../base/common/platform.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -17,19 +16,15 @@ import { TestInstantiationService } from '../../../../../platform/instantiation/ import { ResultKind } from '../../../../../platform/keybinding/common/keybindingResolver.js'; import { TerminalCapability, type ICwdDetectionCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; import { TerminalCapabilityStore } from '../../../../../platform/terminal/common/capabilities/terminalCapabilityStore.js'; -import { GeneralShellType, ITerminalChildProcess, ITerminalProfile, TerminalLocation, TitleEventSource, type IShellLaunchConfig, type ITerminalBackend, type ITerminalProcessOptions } from '../../../../../platform/terminal/common/terminal.js'; +import { GeneralShellType, ITerminalChildProcess, ITerminalProfile, TitleEventSource, type IShellLaunchConfig, type ITerminalBackend, type ITerminalProcessOptions } from '../../../../../platform/terminal/common/terminal.js'; import { IWorkspaceFolder } from '../../../../../platform/workspace/common/workspace.js'; -import { editorBackground } from '../../../../../platform/theme/common/colorRegistry.js'; -import { TestColorTheme } from '../../../../../platform/theme/test/common/testThemeService.js'; -import { PANEL_BACKGROUND, SIDE_BAR_BACKGROUND } from '../../../../common/theme.js'; -import { IViewDescriptorService, ViewContainerLocation } from '../../../../common/views.js'; +import { IViewDescriptorService } from '../../../../common/views.js'; import { ITerminalConfigurationService, ITerminalInstance, ITerminalInstanceService, ITerminalService } from '../../browser/terminal.js'; import { TerminalConfigurationService } from '../../browser/terminalConfigurationService.js'; -import { parseExitResult, TerminalInstance, TerminalInstanceColorProvider, TerminalLabelComputer } from '../../browser/terminalInstance.js'; +import { parseExitResult, TerminalInstance, TerminalLabelComputer } from '../../browser/terminalInstance.js'; import { IEnvironmentVariableService } from '../../common/environmentVariable.js'; import { EnvironmentVariableService } from '../../common/environmentVariableService.js'; import { ITerminalProfileResolverService, ProcessState, DEFAULT_COMMANDS_TO_SKIP_SHELL } from '../../common/terminal.js'; -import { TERMINAL_BACKGROUND_COLOR } from '../../common/terminalColorRegistry.js'; import { TestViewDescriptorService } from './xterm/xtermTerminal.test.js'; import { fixPath } from '../../../../services/search/test/browser/queryBuilder.test.js'; import { TestTerminalProfileResolverService, workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; @@ -636,104 +631,3 @@ suite('Workbench - TerminalInstance', () => { }); }); }); - -suite('TerminalInstanceColorProvider', () => { - const store = ensureNoDisposablesAreLeakedInTestSuite(); - - let configurationService: TestConfigurationService; - let viewDescriptorService: TestViewDescriptorService; - - function createColorProvider(location: TerminalLocation | undefined): TerminalInstanceColorProvider { - const instantiationService = workbenchInstantiationService({ - configurationService: () => configurationService, - }, store); - viewDescriptorService = new TestViewDescriptorService(); - instantiationService.stub(IViewDescriptorService, viewDescriptorService as Partial); - return instantiationService.createInstance(TerminalInstanceColorProvider, new ImmortalReference(location)); - } - - setup(() => { - configurationService = new TestConfigurationService({ - terminal: { - integrated: { - editorUseEditorBackground: true - } - } - }); - }); - - test('editor terminal with editorUseEditorBackground=true returns editor background', () => { - const provider = createColorProvider(TerminalLocation.Editor); - const theme = new TestColorTheme({ - [editorBackground]: '#1e1e1e', - [TERMINAL_BACKGROUND_COLOR]: '#ff0000', - }); - const result = provider.getBackgroundColor(theme); - deepStrictEqual(result, Color.fromHex('#1e1e1e')); - }); - - test('editor terminal with editorUseEditorBackground=false and TERMINAL_BACKGROUND_COLOR defined returns terminal background', () => { - configurationService = new TestConfigurationService({ - terminal: { - integrated: { - editorUseEditorBackground: false - } - } - }); - const provider = createColorProvider(TerminalLocation.Editor); - const theme = new TestColorTheme({ - [editorBackground]: '#1e1e1e', - [TERMINAL_BACKGROUND_COLOR]: '#ff0000', - }); - const result = provider.getBackgroundColor(theme); - deepStrictEqual(result, Color.fromHex('#ff0000')); - }); - - test('editor terminal with editorUseEditorBackground=false and no TERMINAL_BACKGROUND_COLOR falls back to editor background', () => { - configurationService = new TestConfigurationService({ - terminal: { - integrated: { - editorUseEditorBackground: false - } - } - }); - const provider = createColorProvider(TerminalLocation.Editor); - const theme = new TestColorTheme({ - [editorBackground]: '#1e1e1e', - }); - const result = provider.getBackgroundColor(theme); - deepStrictEqual(result, Color.fromHex('#1e1e1e')); - }); - - test('panel terminal ignores editorUseEditorBackground and uses TERMINAL_BACKGROUND_COLOR', () => { - const provider = createColorProvider(TerminalLocation.Panel); - const theme = new TestColorTheme({ - [editorBackground]: '#1e1e1e', - [TERMINAL_BACKGROUND_COLOR]: '#ff0000', - }); - const result = provider.getBackgroundColor(theme); - deepStrictEqual(result, Color.fromHex('#ff0000')); - }); - - test('panel terminal without TERMINAL_BACKGROUND_COLOR falls back to panel background', () => { - const provider = createColorProvider(TerminalLocation.Panel); - viewDescriptorService.moveTerminalToLocation(ViewContainerLocation.Panel); - const theme = new TestColorTheme({ - [PANEL_BACKGROUND]: '#00ff00', - [SIDE_BAR_BACKGROUND]: '#0000ff', - }); - const result = provider.getBackgroundColor(theme); - deepStrictEqual(result, Color.fromHex('#00ff00')); - }); - - test('sidebar terminal without TERMINAL_BACKGROUND_COLOR falls back to sidebar background', () => { - const provider = createColorProvider(TerminalLocation.Panel); - viewDescriptorService.moveTerminalToLocation(ViewContainerLocation.Sidebar); - const theme = new TestColorTheme({ - [PANEL_BACKGROUND]: '#00ff00', - [SIDE_BAR_BACKGROUND]: '#0000ff', - }); - const result = provider.getBackgroundColor(theme); - deepStrictEqual(result, Color.fromHex('#0000ff')); - }); -}); diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts index acb2ca13eaa27..dba3e6271f28e 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts @@ -81,7 +81,7 @@ export class TerminalChatService extends Disposable implements ITerminalChatServ // Clear session auto-approve rules when chat sessions end this._register(this._chatService.onDidDisposeSession(e => { - for (const resource of e.sessionResource) { + for (const resource of e.sessionResources) { this._sessionAutoApproveRules.delete(resource); this._sessionAutoApprovalEnabled.delete(resource); } @@ -105,7 +105,7 @@ export class TerminalChatService extends Disposable implements ITerminalChatServ })); this._register(this._chatService.onDidDisposeSession(e => { - for (const resource of e.sessionResource) { + for (const resource of e.sessionResources) { if (LocalChatSessionUri.parseLocalSessionId(resource) === terminalToolSessionId) { this._terminalInstancesByToolSessionId.delete(terminalToolSessionId); this._toolSessionIdByTerminalInstance.delete(instance); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index c6024ac397e08..3a5188955dcd8 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -512,7 +512,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { // Listen for chat session disposal to clean up associated terminals this._register(this._chatService.onDidDisposeSession(e => { - for (const resource of e.sessionResource) { + for (const resource of e.sessionResources) { this._cleanupSessionTerminals(resource); } })); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index 7ba8cf18603a4..d00448400f958 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -75,7 +75,7 @@ suite('RunInTerminalTool', () => { let storageService: IStorageService; let workspaceContextService: TestContextService; let terminalServiceDisposeEmitter: Emitter; - let chatServiceDisposeEmitter: Emitter<{ sessionResource: URI[]; reason: 'cleared' }>; + let chatServiceDisposeEmitter: Emitter<{ sessionResources: URI[]; reason: 'cleared' }>; let chatSessionArchivedEmitter: Emitter; let sandboxEnabled: boolean; let terminalSandboxService: ITerminalSandboxService; @@ -95,7 +95,7 @@ suite('RunInTerminalTool', () => { setConfig(TerminalChatAgentToolsSettingId.BlockDetectedFileWrites, 'outsideWorkspace'); sandboxEnabled = false; terminalServiceDisposeEmitter = new Emitter(); - chatServiceDisposeEmitter = new Emitter<{ sessionResource: URI[]; reason: 'cleared' }>(); + chatServiceDisposeEmitter = new Emitter<{ sessionResources: URI[]; reason: 'cleared' }>(); chatSessionArchivedEmitter = new Emitter(); instantiationService = workbenchInstantiationService({ @@ -1521,7 +1521,7 @@ suite('RunInTerminalTool', () => { }); runInTerminalTool.sessionTerminalInstances.set(sessionResource, new Set([mockTerminal1, mockTerminal2])); - chatServiceDisposeEmitter.fire({ sessionResource: [sessionResource], reason: 'cleared' }); + chatServiceDisposeEmitter.fire({ sessionResources: [sessionResource], reason: 'cleared' }); strictEqual(terminal1Disposed, true, 'Terminal 1 should have been disposed'); strictEqual(terminal2Disposed, true, 'Terminal 2 should have been disposed'); @@ -1543,7 +1543,7 @@ suite('RunInTerminalTool', () => { ok(runInTerminalTool.sessionTerminalAssociations.has(sessionResource), 'Terminal association should exist before disposal'); - chatServiceDisposeEmitter.fire({ sessionResource: [sessionResource], reason: 'cleared' }); + chatServiceDisposeEmitter.fire({ sessionResources: [sessionResource], reason: 'cleared' }); strictEqual(terminalDisposed, true, 'Terminal should have been disposed'); ok(!runInTerminalTool.sessionTerminalAssociations.has(sessionResource), 'Terminal association should be removed after disposal'); @@ -1574,7 +1574,7 @@ suite('RunInTerminalTool', () => { ok(runInTerminalTool.sessionTerminalAssociations.has(sessionResource1), 'Session 1 terminal association should exist'); ok(runInTerminalTool.sessionTerminalAssociations.has(sessionResource2), 'Session 2 terminal association should exist'); - chatServiceDisposeEmitter.fire({ sessionResource: [sessionResource1], reason: 'cleared' }); + chatServiceDisposeEmitter.fire({ sessionResources: [sessionResource1], reason: 'cleared' }); strictEqual(terminal1Disposed, true, 'Terminal 1 should have been disposed'); strictEqual(terminal2Disposed, false, 'Terminal 2 should NOT have been disposed'); @@ -1584,7 +1584,7 @@ suite('RunInTerminalTool', () => { test('should handle disposal of non-existent session gracefully', () => { strictEqual(runInTerminalTool.sessionTerminalAssociations.size, 0, 'No associations should exist initially'); - chatServiceDisposeEmitter.fire({ sessionResource: [LocalChatSessionUri.forSession('non-existent-session')], reason: 'cleared' }); + chatServiceDisposeEmitter.fire({ sessionResources: [LocalChatSessionUri.forSession('non-existent-session')], reason: 'cleared' }); strictEqual(runInTerminalTool.sessionTerminalAssociations.size, 0, 'No associations should exist after handling non-existent session'); }); }); @@ -1921,7 +1921,7 @@ suite('ChatAgentToolsContribution - tool registration refresh', () => { store.add(fileService.registerProvider(Schemas.file, fileSystemProvider)); const terminalServiceDisposeEmitter = store.add(new Emitter()); - const chatServiceDisposeEmitter = store.add(new Emitter<{ sessionResource: URI[]; reason: 'cleared' }>()); + const chatServiceDisposeEmitter = store.add(new Emitter<{ sessionResources: URI[]; reason: 'cleared' }>()); const chatSessionArchivedEmitter = store.add(new Emitter()); instantiationService = workbenchInstantiationService({ diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/media/gettingStarted.css b/src/vs/workbench/contrib/welcomeGettingStarted/browser/media/gettingStarted.css index fefdf4c9dfe77..f72871782c5f9 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/media/gettingStarted.css +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/media/gettingStarted.css @@ -897,6 +897,16 @@ margin-right: 9px; } +/* Don't show focus outline on mouse click. Instead only show outline on keyboard focus. */ +.monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlideDetails .getting-started-step .codicon:focus { + outline: none; +} + +.monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlideDetails .getting-started-step .codicon:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 0; +} + .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-checkbox.codicon:not(.checked)::before { opacity: 0; diff --git a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts index ac7425f8dd867..b55537066e144 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts @@ -61,14 +61,6 @@ declare module 'vscode' { * when this provider is active. */ readonly unsupportedTypes?: readonly ChatSessionCustomizationType[]; - - /** - * Workspace sub-paths that this provider recognizes for customization files. - * When set, only workspace files under these paths are shown in the UI. - * For example, `['.claude']` for Claude or `['.github', '.copilot']` for CLI. - * When `undefined`, all workspace paths are shown. - */ - readonly workspaceSubpaths?: readonly string[]; } /** @@ -94,6 +86,27 @@ declare module 'vscode' { * Optional description of this customization. */ readonly description?: string; + + /** + * Optional group key for display grouping. Items sharing the same + * `groupKey` are placed under a shared collapsible header in the + * management UI. + * + * When omitted, items are grouped automatically by their storage + * source (e.g. Workspace, User) based on the item's URI. + */ + readonly groupKey?: string; + + /** + * Optional inline badge text shown next to the item name + * (e.g. a glob pattern like `src/vs/sessions/**`). + */ + readonly badge?: string; + + /** + * Optional tooltip text shown when hovering over the badge. + */ + readonly badgeTooltip?: string; } /** diff --git a/test/mcp/package-lock.json b/test/mcp/package-lock.json index 25b41f55f7995..14660d5f9932a 100644 --- a/test/mcp/package-lock.json +++ b/test/mcp/package-lock.json @@ -1085,9 +1085,9 @@ } }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { diff --git a/test/smoke/package-lock.json b/test/smoke/package-lock.json index 39e7b7172bc93..4135802ccf1a8 100644 --- a/test/smoke/package-lock.json +++ b/test/smoke/package-lock.json @@ -513,9 +513,9 @@ } }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": {