diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 5ad346a6db4580..2b9610889308f0 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -89,6 +89,7 @@ "l10n": "./l10n", "enabledApiProposals": [ "agentSessionsWorkspace", + "agentsWindowConfiguration", "chatDebug", "chatHooks", "extensionsAny", @@ -3137,7 +3138,13 @@ "additionalProperties": { "type": "boolean" }, - "markdownDescription": "Enable or disable auto triggering of Copilot completions for specified [languages](https://code.visualstudio.com/docs/languages/identifiers). You can still trigger suggestions manually using `Alt + \\`" + "markdownDescription": "Enable or disable auto triggering of Copilot completions for specified [languages](https://code.visualstudio.com/docs/languages/identifiers). You can still trigger suggestions manually using `Alt + \\`", + "agentsWindow": { + "default": { + "markdown": true, + "plaintext": true + } + } }, "github.copilot.selectedCompletionModel": { "type": "string", @@ -3306,7 +3313,10 @@ "markdownDescription": "%github.copilot.config.githubMcpServer.enabled%", "tags": [ "experimental" - ] + ], + "agentsWindow": { + "default": true + } }, "github.copilot.chat.githubMcpServer.toolsets": { "type": "array", @@ -3652,7 +3662,10 @@ "experimental", "onExP" ], - "markdownDescription": "%github.copilot.chat.languageContext.typescript.enabled%" + "markdownDescription": "%github.copilot.chat.languageContext.typescript.enabled%", + "agentsWindow": { + "default": true + } }, "github.copilot.chat.languageContext.typescript.items": { "type": "string", @@ -4648,7 +4661,10 @@ "tags": [ "advanced", "experimental" - ] + ], + "agentsWindow": { + "default": true + } }, "github.copilot.chat.cli.branchSupport.enabled": { "type": "boolean", @@ -4656,7 +4672,10 @@ "markdownDescription": "%github.copilot.config.cli.branchSupport.enabled%", "tags": [ "advanced" - ] + ], + "agentsWindow": { + "default": true + } }, "github.copilot.chat.cli.showExternalSessions": { "type": "boolean", @@ -4664,7 +4683,10 @@ "markdownDescription": "%github.copilot.config.cli.showExternalSessions%", "tags": [ "advanced" - ] + ], + "agentsWindow": { + "default": false + } }, "github.copilot.chat.cli.planExitMode.enabled": { "type": "boolean", @@ -4704,7 +4726,10 @@ "markdownDescription": "%github.copilot.config.cli.lazyLoadSessionItem.enabled%", "tags": [ "advanced" - ] + ], + "agentsWindow": { + "default": false + } }, "github.copilot.chat.cli.aiGenerateBranchNames.enabled": { "type": "boolean", @@ -4728,7 +4753,10 @@ "markdownDescription": "%github.copilot.config.cli.isolationOption.enabled%", "tags": [ "advanced" - ] + ], + "agentsWindow": { + "default": true + } }, "github.copilot.chat.cli.autoCommit.enabled": { "type": "boolean", @@ -4737,7 +4765,10 @@ "tags": [ "advanced", "experimental" - ] + ], + "agentsWindow": { + "default": false + } }, "github.copilot.chat.cli.sessionController.enabled": { "type": "boolean", @@ -4745,7 +4776,11 @@ "markdownDescription": "%github.copilot.config.cli.sessionController.enabled%", "tags": [ "advanced" - ] + ], + "agentsWindow": { + "default": false, + "readOnly": true + } }, "github.copilot.chat.cli.thinkingEffort.enabled": { "type": "boolean", @@ -4777,7 +4812,10 @@ "markdownDescription": "%github.copilot.config.cli.remote.enabled%", "tags": [ "advanced" - ] + ], + "agentsWindow": { + "default": false + } }, "github.copilot.chat.searchSubagent.enabled": { "type": "boolean", diff --git a/extensions/copilot/src/extension/intents/node/agentIntent.ts b/extensions/copilot/src/extension/intents/node/agentIntent.ts index 7eacaa297a9c61..c6207d36b0006c 100644 --- a/extensions/copilot/src/extension/intents/node/agentIntent.ts +++ b/extensions/copilot/src/extension/intents/node/agentIntent.ts @@ -304,7 +304,8 @@ export class AgentIntent extends EditCodeIntent { if (isBackgroundTodoAgentEnabled(this.configurationService, this.expService, request)) { const todoProcessor = this._backgroundTodoProcessors.get(conversation.sessionId); if (todoProcessor) { - todoProcessor.executeFinalReview(); + const turnId = conversation.getLatestTurn().id; + todoProcessor.requestFinalReview(turnId); await todoProcessor.waitForCompletion(); } } @@ -1233,17 +1234,25 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I this.logService.debug(`[BackgroundTodo] policy decision: ${decision} (${reason})`); - if (decision !== BackgroundTodoDecision.Run || !delta) { - return; - } - - processor.executePass(delta, { + const executionContext = { instantiationService: this.instantiationService, logService: this.logService, toolsService: this.toolsService, telemetryService: this.telemetryService, promptContext, - }, token); + }; + + if (decision === BackgroundTodoDecision.Wait && reason === 'processorInProgress' && delta) { + // Coalesce into the queue so the latest context is not lost. + processor.requestRegularPass(delta, executionContext, token); + return; + } + + if (decision !== BackgroundTodoDecision.Run || !delta) { + return; + } + + processor.requestRegularPass(delta, executionContext, token); } override processResponse = undefined; diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts index 06b1621dad4695..da9b7bec97fedb 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts @@ -492,7 +492,9 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { model: chatEndpoint.model, apiType: chatEndpoint.apiType, transport, + conversationId: telemetryProperties.conversationId ?? conversationId, associatedRequestId: telemetryProperties.associatedRequestId, + parentRequestId: telemetryProperties.parentRequestId, retryAfterError: telemetryProperties.retryAfterError, retryAfterErrorGitHubRequestId: telemetryProperties.retryAfterErrorGitHubRequestId, connectivityTestError: telemetryProperties.connectivityTestError, @@ -638,7 +640,9 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { model: chatEndpoint.model, apiType: chatEndpoint.apiType, transport, + conversationId: telemetryProperties.conversationId ?? conversationId, associatedRequestId: telemetryProperties.associatedRequestId, + parentRequestId: telemetryProperties.parentRequestId, retryAfterError: telemetryProperties.retryAfterError, retryAfterErrorGitHubRequestId: telemetryProperties.retryAfterErrorGitHubRequestId, connectivityTestError: telemetryProperties.connectivityTestError, diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcherTelemetry.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcherTelemetry.ts index 9650b9f9529f94..5e8da4a4cf4d30 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcherTelemetry.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcherTelemetry.ts @@ -37,7 +37,9 @@ export interface IChatMLFetcherCancellationProperties { model: string; apiType: string | undefined; transport: string; + conversationId?: string; associatedRequestId?: string; + parentRequestId?: string; retryAfterError?: string; retryAfterErrorGitHubRequestId?: string; connectivityTestError?: string; @@ -149,7 +151,7 @@ export class ChatMLFetcherTelemetrySender { "resumeEventSeen": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether a system resume event was seen during the request", "isMeasurement": true }, "subType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Sub-type of the request" }, "modelCallId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Unique identifier for this model call" }, - "parentRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The requestId from the parent response.success event for subagent calls" }, + "parentRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "For a subagent: the VS Code chat request id of the parent turn that invoked this subagent (matches panel.request.parentRequestId)." }, "parentModelCallId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Model call ID of the parent request for subagent calls" }, "iterationNumber": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Iteration number within the tool calling loop" } } @@ -166,11 +168,11 @@ export class ChatMLFetcherTelemetrySender { requestId: chatCompletion.requestId.headerRequestId, gitHubRequestId: chatCompletion.requestId.gitHubRequestId, associatedRequestId: baseTelemetry?.properties.associatedRequestId, + parentRequestId: baseTelemetry?.properties.parentRequestId, reasoningEffort: requestBody.reasoning?.effort ?? requestBody.output_config?.effort, reasoningSummary: requestBody.reasoning?.summary, modelCallId, ...(baseTelemetry?.properties.subType ? { subType: baseTelemetry.properties.subType } : {}), - ...(baseTelemetry?.properties.parentHeaderRequestId ? { parentRequestId: baseTelemetry.properties.parentHeaderRequestId } : {}), ...(baseTelemetry?.properties.parentModelCallId ? { parentModelCallId: baseTelemetry.properties.parentModelCallId } : {}), ...(baseTelemetry?.properties.iterationNumber ? { iterationNumber: baseTelemetry.properties.iterationNumber } : {}), ...(fetcher ? { fetcher } : {}), @@ -212,7 +214,9 @@ export class ChatMLFetcherTelemetrySender { model, apiType, transport, + conversationId, associatedRequestId, + parentRequestId, retryAfterError, retryAfterErrorGitHubRequestId, connectivityTestError, @@ -244,7 +248,9 @@ export class ChatMLFetcherTelemetrySender { "apiType": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "API type for the response- chat completions or responses" }, "source": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Source for why the request was made" }, "requestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Id of the request" }, + "conversationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Id for the current chat conversation." }, "associatedRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Another request ID that this request is associated with (eg, the originating request of a summarization request)." }, + "parentRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "For a subagent: the request id of the main agent request that invoked this subagent." }, "fetcher": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The fetcher used for the request" }, "transport": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The transport used for the request (http or websocket)" }, "totalTokenMax": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Maximum total token window", "isMeasurement": true }, @@ -273,7 +279,9 @@ export class ChatMLFetcherTelemetrySender { source, requestId, model, + conversationId, associatedRequestId, + parentRequestId, ...(fetcher ? { fetcher } : {}), transport, ...(retryAfterError ? { retryAfterError } : {}), @@ -330,7 +338,9 @@ export class ChatMLFetcherTelemetrySender { "source": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Source for why the request was made" }, "requestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Id of the request" }, "gitHubRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "GitHub request id if available" }, + "conversationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Id for the current chat conversation." }, "associatedRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Another request ID that this request is associated with (eg, the originating request of a summarization request)." }, + "parentRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "For a subagent: the request id of the main agent request that invoked this subagent." }, "reasoningEffort": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Reasoning effort level" }, "reasoningSummary": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Reasoning summary level" }, "fetcher": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The fetcher used for the request" }, @@ -364,11 +374,13 @@ export class ChatMLFetcherTelemetrySender { gitHubRequestId: processed.serverRequestId, model: chatEndpointInfo.model, apiType: chatEndpointInfo.apiType, + conversationId: telemetryProperties?.conversationId, reasoningEffort: requestBody.reasoning?.effort ?? requestBody.output_config?.effort, reasoningSummary: requestBody.reasoning?.summary, ...(fetcher ? { fetcher } : {}), transport, associatedRequestId: telemetryProperties?.associatedRequestId, + parentRequestId: telemetryProperties?.parentRequestId, ...(telemetryProperties?.retryAfterError ? { retryAfterError: telemetryProperties.retryAfterError } : {}), ...(telemetryProperties?.retryAfterErrorGitHubRequestId ? { retryAfterErrorGitHubRequestId: telemetryProperties.retryAfterErrorGitHubRequestId } : {}), ...(telemetryProperties?.connectivityTestError ? { connectivityTestError: telemetryProperties.connectivityTestError } : {}), diff --git a/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts b/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts index 0b3cac9cb672c6..d58790223546ac 100644 --- a/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts +++ b/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts @@ -361,6 +361,7 @@ export class ExecutionSubagentToolCallingLoop extends ToolCallingLoop | undefined; private _cts: CancellationTokenSource | undefined; private _lastError: unknown; - private _pendingDelta: IBackgroundTodoDelta | undefined; - /** Work callback associated with {@link _pendingDelta}. Captured at queue time so a - * coalesced finalize pass keeps its finalize-mode closure (regular per-round work - * would otherwise overwrite finalize-mode behavior when the queued delta drains). */ - private _pendingWork: ((delta: IBackgroundTodoDelta, token: CancellationToken) => Promise) | undefined; private _hasCreatedTodos: boolean = false; private _passCount: number = 0; - /** Cached on the most recent {@link executePass} call so {@link executeFinalReview} - * can re-use the same services + most recent prompt context without a fresh build. */ + + // ── Two-slot queue ────────────────────────────────────────── + // Regular passes coalesce into one slot; final review occupies a + // second independent slot that drains only after all regular work. + + private _pendingRegularDelta: IBackgroundTodoDelta | undefined; + private _pendingRegularContext: IBackgroundTodoExecutionContext | undefined; + private _pendingRegularToken: CancellationToken | undefined; + + /** Pending final-review execution context. When set, {@link _drainQueue} + * will run a finalize pass after all regular work has drained. */ + private _pendingFinalReview: IBackgroundTodoExecutionContext | undefined; + private _pendingFinalReviewToken: CancellationToken | undefined; + /** Turn ID for which final review has already been attempted/queued. + * Prevents duplicate finalize passes within a single turn. */ + private _finalReviewAttemptedTurnId: string | undefined; + /** The most recent execution context from any {@link requestRegularPass} + * call. Used by {@link requestFinalReview} to build the synthetic + * final-review delta when no explicit context is provided. */ private _lastExecutionContext: IBackgroundTodoExecutionContext | undefined; - /** True after a final review pass has been queued for this turn; reset on the next - * regular {@link executePass}. Prevents duplicate finalize passes. */ - private _finalReviewQueued: boolean = false; readonly deltaTracker = new BackgroundTodoDeltaTracker(); @@ -215,41 +229,224 @@ export class BackgroundTodoProcessor { return { decision: BackgroundTodoDecision.Wait, reason: 'contextOnlyWaiting', delta }; } + // ── Public queue API ──────────────────────────────────────── + + /** + * Enqueue or coalesce a regular background pass. If a pass is already + * running, the delta is stashed and will drain when the current pass + * completes. Always updates {@link _lastExecutionContext}. + */ + requestRegularPass( + delta: IBackgroundTodoDelta, + context: IBackgroundTodoExecutionContext, + parentToken?: CancellationToken, + ): void { + this._lastExecutionContext = context; + this._logService?.debug(`[BackgroundTodo] requestRegularPass — newRounds=${delta.metadata.newRoundCount}, meaningful=${delta.metadata.meaningfulToolCallCount}, state=${this._state}`); + this._pendingRegularDelta = delta; + this._pendingRegularContext = context; + this._pendingRegularToken = parentToken; + this._drainQueue(); + } + + /** + * Request a single final-review pass for this turn. The pass runs + * after all pending regular work has drained, regardless of whether + * the processor is currently Idle, InProgress, or Failed. + * + * No-op when: + * - No execution context has been recorded (no prompt build happened). + * - No todos have been created yet (nothing to finalize). + * - Final review was already requested for the given {@link turnId}. + */ + requestFinalReview(turnId: string, parentToken?: CancellationToken): void { + if (!this._hasCreatedTodos || !this._lastExecutionContext) { + this._logService?.debug(`[BackgroundTodo] final review skipped — hasCreatedTodos=${this._hasCreatedTodos}, hasExecutionContext=${this._lastExecutionContext !== undefined}`); + return; + } + if (this._finalReviewAttemptedTurnId === turnId) { + this._logService?.debug(`[BackgroundTodo] final review skipped — already attempted for turn ${turnId}`); + return; + } + this._finalReviewAttemptedTurnId = turnId; + this._logService?.debug(`[BackgroundTodo] final review requested for turn ${turnId} — currentState=${this._state}`); + + this._pendingFinalReview = { ...this._lastExecutionContext, isFinalReview: true }; + this._pendingFinalReviewToken = parentToken; + this._drainQueue(); + } + + /** + * Wait for any in-flight pass — and any pending queued pass that drains + * from it — to settle. Returns immediately if idle with nothing queued. + */ + async waitForCompletion(): Promise { + while (this._promise) { + const current = this._promise; + await current; + // If _drainQueue started a new pass, _promise has been replaced. + // Loop until no new work was queued. + if (this._promise === current) { + break; + } + } + } + + // ── Low-level start (kept for direct unit tests) ──────────── + /** * Start a background pass if one is not already running. * - * If a pass is in progress, the delta is stashed and will be processed - * automatically when the current pass completes. + * If a pass is in progress, the delta is stashed as a pending regular + * pass and will drain via {@link _drainQueue} when the current pass + * completes. * * @param delta The new activity to process. * @param work An async function that performs the actual model call and * tool invocation. It receives a cancellation token. * @param parentToken Optional parent cancellation token. + * @param advanceCursor Whether to advance the delta tracker cursor on + * success. Regular passes set this to `true`; final review sets + * it to `false` so it does not interfere with regular-pass tracking. */ start( delta: IBackgroundTodoDelta, work: (delta: IBackgroundTodoDelta, token: CancellationToken) => Promise, parentToken?: CancellationToken, + advanceCursor: boolean = true, ): void { if (this._state === BackgroundTodoProcessorState.InProgress) { - // Coalesce: stash the latest delta AND its work callback for when the current - // pass finishes. Storing the callback is critical for finalize passes — they - // carry an `isFinalReview: true` execution context in the closure that must - // survive the queue. Without this, a finalize pass queued behind a regular - // pass would silently drain in regular mode. - this._logService?.debug(`[BackgroundTodo] coalescing delta (pass #${this._passCount} in progress) — newRounds=${delta.metadata.newRoundCount}, meaningful=${delta.metadata.meaningfulToolCallCount}, replacingPending=${this._pendingDelta !== undefined}`); - this._pendingDelta = delta; - this._pendingWork = work; + // Coalesce into the regular-pass slot so _drainQueue picks it up. + this._logService?.debug(`[BackgroundTodo] coalescing delta (pass #${this._passCount} in progress) — newRounds=${delta.metadata.newRoundCount}, meaningful=${delta.metadata.meaningfulToolCallCount}`); + this._pendingRegularDelta = delta; + this._pendingRegularContext = undefined; // will use work callback directly + this._pendingRegularToken = parentToken; + // Stash the work callback so _drainQueue can use it for the + // coalesced pass (preserves finalize-mode closures). + this._pendingRegularWork = work; + this._pendingRegularAdvanceCursor = advanceCursor; return; } - this._runPass(delta, work, parentToken); + this._runPass(delta, work, parentToken, advanceCursor); + } + + /** Stashed work callback for coalesced start() calls. */ + private _pendingRegularWork: ((delta: IBackgroundTodoDelta, token: CancellationToken) => Promise) | undefined; + private _pendingRegularAdvanceCursor: boolean = true; + + // ── Internal execution ────────────────────────────────────── + + /** + * Central scheduler. Called after every state transition and after + * every enqueue. Picks the next item to run: + * 1. Pending regular pass (coalesced — only the latest survives). + * 2. Pending final review. + * Does nothing if a pass is already running. + */ + private _drainQueue(): void { + if (this._state === BackgroundTodoProcessorState.InProgress) { + return; + } + + // ── Regular pass first ────────────────────────────────── + const regularDelta = this._pendingRegularDelta; + if (regularDelta) { + const ctx = this._pendingRegularContext; + const token = this._pendingRegularToken; + const stashedWork = this._pendingRegularWork; + const advanceCursor = this._pendingRegularAdvanceCursor; + this._pendingRegularDelta = undefined; + this._pendingRegularContext = undefined; + this._pendingRegularToken = undefined; + this._pendingRegularWork = undefined; + this._pendingRegularAdvanceCursor = true; + + if (stashedWork) { + // Coalesced via start() — use the stashed callback directly. + this._runPass(regularDelta, stashedWork, token, advanceCursor); + return; + } else if (ctx) { + // Enqueued via requestRegularPass — recompute against the latest cursor. + // This avoids replaying the in-flight delta when no new rounds arrived + // while the previous pass was running, and retries the full delta if the + // previous pass failed and did not advance the cursor. + const latestDelta = this.deltaTracker.peekDelta(ctx.promptContext); + if (!latestDelta) { + this._logService?.debug('[BackgroundTodo] queued regular pass skipped: no new delta remains after in-flight pass'); + } else { + this._runPass( + latestDelta, + (d, t) => BackgroundTodoProcessor._doExecute(d, ctx, t), + token, + true, // regular passes always advance cursor + ); + return; + } + } else { + this._logService?.debug('[BackgroundTodo] queued regular pass skipped: missing execution context'); + } + } + + // ── Final review ──────────────────────────────────────── + const finalCtx = this._pendingFinalReview; + if (finalCtx) { + const token = this._pendingFinalReviewToken; + this._pendingFinalReview = undefined; + this._pendingFinalReviewToken = undefined; + + // Build a synthetic delta from the full trajectory so the + // finalize prompt sees every round. + const allRounds = collectAllRounds( + finalCtx.promptContext.history, + finalCtx.promptContext.toolCallRounds ?? [], + ); + if (allRounds.length === 0) { + return; + } + let meaningful = 0; + let contextual = 0; + for (const round of allRounds) { + for (const call of round.toolCalls) { + const category = classifyTool(call.name); + if (category === 'meaningful') { + meaningful++; + } else if (category === 'context') { + contextual++; + } + } + } + const delta: IBackgroundTodoDelta = { + userRequest: finalCtx.promptContext.query, + newRounds: allRounds, + history: finalCtx.promptContext.history, + sessionResource: extractSessionResource(finalCtx.promptContext), + metadata: { + newRoundCount: allRounds.length, + newToolCallCount: meaningful + contextual, + meaningfulToolCallCount: meaningful, + contextToolCallCount: contextual, + isInitialDelta: false, + isRequestOnly: false, + }, + }; + + this._logService?.debug(`[BackgroundTodo] draining final review — rounds=${allRounds.length}, meaningful=${meaningful}, context=${contextual}`); + this._runPass( + delta, + (d, t) => BackgroundTodoProcessor._doExecute(d, finalCtx, t), + token, + false, // final review must NOT advance the regular-pass cursor + ); + return; + } } private _runPass( delta: IBackgroundTodoDelta, work: (delta: IBackgroundTodoDelta, token: CancellationToken) => Promise, parentToken?: CancellationToken, + advanceCursor: boolean = true, ): void { this._passCount++; const passNum = this._passCount; @@ -259,7 +456,7 @@ export class BackgroundTodoProcessor { this._cts = cts; const token = cts.token; - this._logService?.debug(`[BackgroundTodo] starting pass #${passNum} — newRounds=${delta.metadata.newRoundCount}, meaningful=${delta.metadata.meaningfulToolCallCount}, context=${delta.metadata.contextToolCallCount}`); + this._logService?.debug(`[BackgroundTodo] starting pass #${passNum} — newRounds=${delta.metadata.newRoundCount}, meaningful=${delta.metadata.meaningfulToolCallCount}, context=${delta.metadata.contextToolCallCount}, advanceCursor=${advanceCursor}`); const passPromise = work(delta, token).then( (result) => { @@ -271,11 +468,13 @@ export class BackgroundTodoProcessor { this._hasCreatedTodos = true; } this._logService?.debug(`[BackgroundTodo] pass #${passNum} completed: outcome=${result.outcome}, durationMs=${result.durationMs ?? '?'}, model=${result.model ?? '?'}, promptTokens=${result.promptTokens ?? '?'}, completionTokens=${result.completionTokens ?? '?'}`); - this.deltaTracker.markProcessed(delta); + if (advanceCursor) { + this.deltaTracker.markProcessed(delta); + } this._disposeCts(cts); this._state = BackgroundTodoProcessorState.Idle; - const hasPending = this._checkPending(work, parentToken); - if (!hasPending && this._promise === passPromise) { + this._drainQueue(); + if (!this._promise || this._promise === passPromise) { this._promise = undefined; } }, @@ -289,8 +488,8 @@ export class BackgroundTodoProcessor { this._logService?.warn(`[BackgroundTodo] pass #${passNum} failed: ${err}`); // Do NOT advance the cursor — the delta's rounds remain unprocessed // so a subsequent pass can retry with fresh or coalesced activity. - const hasPending = this._checkPending(work, parentToken); - if (!hasPending && this._promise === passPromise) { + this._drainQueue(); + if (!this._promise || this._promise === passPromise) { this._promise = undefined; } }, @@ -305,134 +504,6 @@ export class BackgroundTodoProcessor { cts.dispose(); } - /** - * If a delta was stashed while a pass was running, start a new pass now. - */ - private _checkPending( - work: (delta: IBackgroundTodoDelta, token: CancellationToken) => Promise, - parentToken?: CancellationToken, - ): boolean { - const pending = this._pendingDelta; - if (pending) { - // Prefer the work callback that was stashed alongside the pending delta — - // it preserves finalize-mode (or any future per-pass) context in its closure. - // Fall back to the caller-provided work only if no stashed callback exists. - const pendingWork = this._pendingWork ?? work; - const usingStashed = this._pendingWork !== undefined; - this._logService?.debug(`[BackgroundTodo] draining pending delta — newRounds=${pending.metadata.newRoundCount}, meaningful=${pending.metadata.meaningfulToolCallCount}, usingStashedWork=${usingStashed}`); - this._pendingDelta = undefined; - this._pendingWork = undefined; - this._runPass(pending, pendingWork, parentToken); - return true; - } - return false; - } - - /** - * Wait for any in-flight pass — and any pending coalesced pass that drains - * from it — to settle. Returns immediately if idle. - */ - async waitForCompletion(): Promise { - while (this._promise) { - const current = this._promise; - await current; - // If _checkPending started a new pass, _promise has been replaced. - // Loop until no new work was queued. - if (this._promise === current) { - break; - } - } - } - - // ── Execution ────────────────────────────────────────────── - - /** - * Convenience method: starts a background pass using the built-in - * execution logic (acquire copilot-fast endpoint → render prompt → - * call model → invoke todo tool). - */ - executePass( - delta: IBackgroundTodoDelta, - context: IBackgroundTodoExecutionContext, - parentToken?: CancellationToken, - ): void { - this._lastExecutionContext = context; - this._finalReviewQueued = false; - this.start( - delta, - (d, token) => BackgroundTodoProcessor._doExecute(d, context, token), - parentToken, - ); - } - - /** - * Fire one extra background pass after the agent loop has ended for this turn. - * - * The regular per-round bg passes never see the very last round (there is no - * follow-up `buildPrompt` to fire against), so any task that *just* completed - * on the final round stays stuck as 'in-progress' until the next user turn. - * This pass uses the cached execution context from the most recent - * {@link executePass} and runs in finalize mode so the model focuses on - * promoting completed work rather than re-planning. - * - * No-op when: - * - No bg pass has ever run for this session (no cached context). - * - No todos exist yet (nothing to finalize). - * - A final review has already been queued for this turn. - */ - executeFinalReview(parentToken?: CancellationToken): void { - if (this._finalReviewQueued || !this._hasCreatedTodos || !this._lastExecutionContext) { - this._logService?.debug(`[BackgroundTodo] final review skipped — alreadyQueued=${this._finalReviewQueued}, hasCreatedTodos=${this._hasCreatedTodos}, hasExecutionContext=${this._lastExecutionContext !== undefined}`); - return; - } - this._finalReviewQueued = true; - this._logService?.debug(`[BackgroundTodo] final review requested — currentState=${this._state}`); - - const ctx = this._lastExecutionContext; - const finalCtx: IBackgroundTodoExecutionContext = { ...ctx, isFinalReview: true }; - - // Build a synthetic delta that includes every round we know about so the - // finalize prompt has full trajectory context. Skip cursor advancement - // (markProcessed is intentionally not called) since this is a one-shot review. - const allRounds = collectAllRounds(ctx.promptContext.history, ctx.promptContext.toolCallRounds ?? []); - if (allRounds.length === 0) { - return; - } - let meaningful = 0; - let contextual = 0; - for (const round of allRounds) { - for (const call of round.toolCalls) { - const category = classifyTool(call.name); - if (category === 'meaningful') { - meaningful++; - } else if (category === 'context') { - contextual++; - } - } - } - const delta: IBackgroundTodoDelta = { - userRequest: ctx.promptContext.query, - newRounds: allRounds, - history: ctx.promptContext.history, - sessionResource: extractSessionResource(ctx.promptContext), - metadata: { - newRoundCount: allRounds.length, - newToolCallCount: meaningful + contextual, - meaningfulToolCallCount: meaningful, - contextToolCallCount: contextual, - isInitialDelta: false, - isRequestOnly: false, - }, - }; - - this._logService?.debug(`[BackgroundTodo] queueing final review — rounds=${allRounds.length}, meaningful=${meaningful}, context=${contextual}`); - this.start( - delta, - (d, token) => BackgroundTodoProcessor._doExecute(d, finalCtx, token), - parentToken, - ); - } - /** * The actual background work: render the todo prompt against copilot-fast, * parse tool calls, and invoke the todo tool. @@ -652,10 +723,15 @@ export class BackgroundTodoProcessor { this._state = BackgroundTodoProcessorState.Idle; this._lastError = undefined; this._promise = undefined; - this._pendingDelta = undefined; - this._pendingWork = undefined; + this._pendingRegularDelta = undefined; + this._pendingRegularContext = undefined; + this._pendingRegularToken = undefined; + this._pendingRegularWork = undefined; + this._pendingRegularAdvanceCursor = true; + this._pendingFinalReview = undefined; + this._pendingFinalReviewToken = undefined; this._lastExecutionContext = undefined; - this._finalReviewQueued = false; + this._finalReviewAttemptedTurnId = undefined; } } diff --git a/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoPrompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoPrompt.tsx index 29e8c99ba03db2..18a1b404a1e367 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoPrompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/backgroundTodoPrompt.tsx @@ -15,7 +15,7 @@ export interface BackgroundTodoPromptProps extends BasePromptElementProps { readonly history: IBackgroundTodoHistory; /** When true, the prompt switches to finalize mode: the agent loop has ended and * the bg agent should mark any in-progress items now-complete based on the full - * trajectory. See {@link BackgroundTodoProcessor.executeFinalReview}. */ + * trajectory. See {@link BackgroundTodoProcessor.requestFinalReview}. */ readonly isFinalReview?: boolean; } diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoProcessor.spec.ts b/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoProcessor.spec.ts index 953e5cbe231b0f..d1cef11d29dd67 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoProcessor.spec.ts +++ b/extensions/copilot/src/extension/prompts/node/agent/test/backgroundTodoProcessor.spec.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { describe, expect, test } from 'vitest'; -import { BackgroundTodoProcessor, BackgroundTodoProcessorState, IBackgroundTodoResult } from '../backgroundTodoProcessor'; +import { BackgroundTodoProcessor, BackgroundTodoProcessorState, IBackgroundTodoExecutionContext, IBackgroundTodoResult } from '../backgroundTodoProcessor'; import { IBackgroundTodoDelta } from '../backgroundTodoDelta'; import { CancellationTokenSource } from '../../../../../util/vs/base/common/cancellation'; @@ -30,6 +30,39 @@ function makeDelta(rounds: string[] = []): IBackgroundTodoDelta { }; } +interface IExecutionContextTestOptions { + readonly endpointDelayMs?: number; + readonly logMessages?: string[]; + readonly telemetryEvents?: string[]; +} + +function makeLogService(logMessages?: string[]) { + return { + debug: (message: string) => logMessages?.push(message), + warn: (message: string) => logMessages?.push(message), + } as any; +} + +function makeExecutionContext(rounds: string[] = [], options: IExecutionContextTestOptions = {}): IBackgroundTodoExecutionContext { + return { + instantiationService: { invokeFunction: async () => { + if (options.endpointDelayMs !== undefined) { + await new Promise(resolve => setTimeout(resolve, options.endpointDelayMs)); + } + throw new Error('no endpoint'); + } } as any, + logService: makeLogService(options.logMessages), + toolsService: { invokeTool: async () => undefined } as any, + telemetryService: { sendMSFTTelemetryEvent: (eventName: string) => options.telemetryEvents?.push(eventName) } as any, + promptContext: { + query: 'fix the bug', + history: [], + chatVariables: { hasVariables: () => false } as any, + toolCallRounds: rounds.map(id => ({ id, response: '', toolInputRetry: 0, toolCalls: [] })), + } as any, + }; +} + describe('BackgroundTodoProcessor', () => { test('initial state is Idle', () => { @@ -87,6 +120,19 @@ describe('BackgroundTodoProcessor', () => { })).toBeDefined(); }); + test('delta cursor does NOT advance when advanceCursor is false', async () => { + const processor = new BackgroundTodoProcessor(); + processor.start(makeDelta(['r1']), async () => ({ outcome: 'success' }), undefined, false); + await processor.waitForCompletion(); + + expect(processor.deltaTracker.getDelta({ + query: 'fix', + history: [], + chatVariables: { hasVariables: () => false } as any, + toolCallRounds: [{ id: 'r1', response: '', toolInputRetry: 0, toolCalls: [] }], + })).toBeDefined(); + }); + test('coalesces concurrent updates', async () => { const processor = new BackgroundTodoProcessor(); let workCallCount = 0; @@ -114,6 +160,26 @@ describe('BackgroundTodoProcessor', () => { expect(workCallCount).toBe(2); }); + test('requestRegularPass skips queued work when only in-flight rounds were present', async () => { + const telemetryEvents: string[] = []; + const context = makeExecutionContext(['r1'], { endpointDelayMs: 20, telemetryEvents }); + const processor = new BackgroundTodoProcessor(); + + processor.requestRegularPass(makeDelta(['r1']), context); + processor.requestRegularPass(makeDelta(['r1']), context); + await processor.waitForCompletion(); + + expect({ + state: processor.state, + telemetryEventCount: telemetryEvents.length, + hasRemainingDelta: processor.deltaTracker.getDelta(context.promptContext) !== undefined, + }).toEqual({ + state: BackgroundTodoProcessorState.Idle, + telemetryEventCount: 1, + hasRemainingDelta: false, + }); + }); + test('cancel stops in-flight work', async () => { const processor = new BackgroundTodoProcessor(); let completed = false; @@ -147,23 +213,105 @@ describe('BackgroundTodoProcessor', () => { cts.dispose(); }); - test('executeFinalReview is a no-op when no executePass has run', () => { + // ── requestFinalReview ────────────────────────────────────── + + test('requestFinalReview is a no-op when no context has been recorded', () => { const processor = new BackgroundTodoProcessor(); - processor.executeFinalReview(); + processor.requestFinalReview('turn-1'); expect(processor.state).toBe(BackgroundTodoProcessorState.Idle); }); - test('executeFinalReview is a no-op when no todos have been created', async () => { + test('requestFinalReview is a no-op when no todos have been created', async () => { const processor = new BackgroundTodoProcessor(); // Simulate a noop pass so a context exists but hasCreatedTodos remains false processor.start(makeDelta(['r1']), async () => ({ outcome: 'noop' })); await processor.waitForCompletion(); expect(processor.hasCreatedTodos).toBe(false); - processor.executeFinalReview(); - // Should not transition to InProgress because hasCreatedTodos is false + processor.requestFinalReview('turn-1'); + expect(processor.state).toBe(BackgroundTodoProcessorState.Idle); + }); + + test('requestFinalReview runs when processor is idle and todos exist', async () => { + const processor = new BackgroundTodoProcessor(); + // Use requestRegularPass so _lastExecutionContext is recorded + processor.requestRegularPass(makeDelta(['r1']), makeExecutionContext(['r1'])); + // Force hasCreatedTodos + await processor.waitForCompletion(); + // The work threw because the mock context has no real endpoint, but + // we need hasCreatedTodos = true. Use the low-level start() for that. + processor.start(makeDelta(['r2']), async () => ({ outcome: 'success' })); + await processor.waitForCompletion(); + expect(processor.hasCreatedTodos).toBe(true); + expect(processor.state).toBe(BackgroundTodoProcessorState.Idle); + + // Now request final review — it should transition to InProgress + processor.requestFinalReview('turn-1'); + expect(processor.state).toBe(BackgroundTodoProcessorState.InProgress); + await processor.waitForCompletion(); + }); + + test('requestFinalReview deduplicates by turn ID', async () => { + const processor = new BackgroundTodoProcessor(); + processor.start(makeDelta(['r1']), async () => ({ outcome: 'success' })); + await processor.waitForCompletion(); + // Record a context + processor.requestRegularPass(makeDelta(['r2']), makeExecutionContext(['r2'])); + await processor.waitForCompletion(); + + // First request should be accepted + processor.requestFinalReview('turn-1'); + expect(processor.state).toBe(BackgroundTodoProcessorState.InProgress); + await processor.waitForCompletion(); + + // Second request with same turn ID should be a no-op + processor.requestFinalReview('turn-1'); expect(processor.state).toBe(BackgroundTodoProcessorState.Idle); }); + test('requestFinalReview drains after a regular pass completes', async () => { + const logMessages: string[] = []; + const telemetryEvents: string[] = []; + const processor = new BackgroundTodoProcessor(makeLogService(logMessages)); + const ranWork: string[] = []; + + processor.start(makeDelta(['r0']), async () => ({ outcome: 'success' })); + await processor.waitForCompletion(); + logMessages.length = 0; + + // Start a slow regular pass + processor.start(makeDelta(['r1']), async () => { + ranWork.push('regular'); + await new Promise(resolve => setTimeout(resolve, 50)); + return { outcome: 'success' }; + }); + + // While in progress, record context and request final review + processor.requestRegularPass(makeDelta(['r2']), makeExecutionContext(['r1', 'r2'], { telemetryEvents })); + processor.requestFinalReview('turn-1'); + + await processor.waitForCompletion(); + + const passStartIndexes = logMessages + .map((message, index) => message.includes('starting pass #') ? index : -1) + .filter(index => index !== -1); + const finalReviewIndex = logMessages.findIndex(message => message.includes('draining final review')); + expect({ + state: processor.state, + ranWork, + telemetryEventCount: telemetryEvents.length, + passStartCount: passStartIndexes.length, + coalescedRegularBeforeFinalReview: passStartIndexes[1] !== undefined && passStartIndexes[1] < finalReviewIndex, + finalReviewBeforeFinalPass: passStartIndexes[2] !== undefined && finalReviewIndex < passStartIndexes[2], + }).toEqual({ + state: BackgroundTodoProcessorState.Idle, + ranWork: ['regular'], + telemetryEventCount: 2, + passStartCount: 3, + coalescedRegularBeforeFinalReview: true, + finalReviewBeforeFinalPass: true, + }); + }); + test('coalesced pending delta runs with its own queued work callback', async () => { const processor = new BackgroundTodoProcessor(); const ranWork: string[] = []; @@ -176,9 +324,7 @@ describe('BackgroundTodoProcessor', () => { }); expect(processor.state).toBe(BackgroundTodoProcessorState.InProgress); - // Queue a second pass with a *different* work callback (workB). This simulates - // executeFinalReview queuing a finalize-mode work closure while a regular pass - // is still in flight. The drained pass MUST run workB, not workA. + // Queue a second pass with a *different* work callback (workB). processor.start(makeDelta(['r2']), async () => { ranWork.push('B'); return { outcome: 'success' }; @@ -188,7 +334,7 @@ describe('BackgroundTodoProcessor', () => { expect(ranWork).toEqual(['A', 'B']); }); - test('cancel clears pending coalesced work', async () => { + test('cancel clears pending coalesced work and final review', async () => { const processor = new BackgroundTodoProcessor(); const ranWork: string[] = []; diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/summarization.spec.tsx b/extensions/copilot/src/extension/prompts/node/agent/test/summarization.spec.tsx index 2fbb815ec0718a..dec03abd2f66fc 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/summarization.spec.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/test/summarization.spec.tsx @@ -31,6 +31,7 @@ import { PromptRenderer } from '../../base/promptRenderer'; import { AgentPrompt, AgentPromptProps } from '../agentPrompt'; import { PromptRegistry } from '../promptRegistry'; import { ISessionTranscriptService, NullSessionTranscriptService } from '../../../../../platform/chat/common/sessionTranscriptService'; +import { ITokenizerProvider } from '../../../../../platform/tokenizer/node/tokenizer'; import { appendTranscriptHintToSummary, ConversationHistorySummarizationPrompt, extractSummary, stripToolSearchMessages, SummarizedConversationHistory, SummarizedConversationHistoryMetadata, SummarizedConversationHistoryPropsBuilder } from '../summarizedConversationHistory'; suite('Agent Summarization', () => { @@ -40,7 +41,7 @@ suite('Agent Summarization', () => { let conversation: Conversation; - beforeAll(() => { + beforeAll(async () => { const testDoc = createTextDocumentData(fileTsUri, 'line 1\nline 2\n\nline 4\nline 5', 'ts').document; const services = createExtensionUnitTestingServices(); @@ -57,6 +58,12 @@ suite('Agent Summarization', () => { accessor.get(IConfigurationService).setConfig(ConfigKey.CodeGenerationInstructions, [{ text: 'This is a test custom instruction file', } satisfies CodeGenerationTextInstruction]); + + // Warm up the tokenizer once so per-test timing is predictable. The first + // tokenizer use parses a ~3.6MB BPE file which can take seconds on slow CI + // machines and would otherwise be charged to whichever test runs first. + const endpoint = accessor.get(IInstantiationService).createInstance(MockEndpoint, undefined); + await accessor.get(ITokenizerProvider).acquireTokenizer(endpoint).tokenLength('warmup'); }); beforeEach(() => { diff --git a/extensions/git/package.json b/extensions/git/package.json index fe791389aaa067..0f8a4252c49120 100644 --- a/extensions/git/package.json +++ b/extensions/git/package.json @@ -11,6 +11,7 @@ "aiKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255", "enabledApiProposals": [ "agentSessionsWorkspace", + "agentsWindowConfiguration", "canonicalUriProvider", "contribEditSessions", "contribEditorContentMenu", @@ -3321,7 +3322,10 @@ "git.autorefresh": { "type": "boolean", "description": "%config.autorefresh%", - "default": true + "default": true, + "agentsWindow": { + "default": true + } }, "git.autofetch": { "type": [ @@ -3338,7 +3342,10 @@ "default": false, "tags": [ "usesOnlineServices" - ] + ], + "agentsWindow": { + "default": true + } }, "git.autofetchPeriod": { "type": "number", @@ -3397,7 +3404,10 @@ "type": "boolean", "description": "%config.branchRandomNameEnable%", "default": false, - "scope": "resource" + "scope": "resource", + "agentsWindow": { + "default": true + } }, "git.branchRandomName.dictionary": { "type": "array", @@ -3695,7 +3705,10 @@ "type": "boolean", "scope": "resource", "default": false, - "description": "%config.detectWorktrees%" + "description": "%config.detectWorktrees%", + "agentsWindow": { + "default": false + } }, "git.detectWorktreesLimit": { "type": "number", @@ -3771,7 +3784,11 @@ "type": "boolean", "description": "%config.showProgress%", "default": true, - "scope": "resource" + "scope": "resource", + "agentsWindow": { + "default": false, + "readOnly": true + } }, "git.rebaseWhenSync": { "type": "boolean", diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 4a2fee7c4c232c..6201b35001ccfc 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -741,7 +741,7 @@ }, "markdown.preview.markEditorSelection": { "type": "boolean", - "default": true, + "default": false, "description": "%markdown.preview.markEditorSelection.desc%", "scope": "resource" }, @@ -753,7 +753,7 @@ }, "markdown.preview.doubleClickToSwitchToEditor": { "type": "boolean", - "default": true, + "default": false, "description": "%markdown.preview.doubleClickToSwitchToEditor.desc%", "scope": "resource" }, diff --git a/src/vs/base/common/defaultAccount.ts b/src/vs/base/common/defaultAccount.ts index 83b98183bcf20c..f062af631e5ace 100644 --- a/src/vs/base/common/defaultAccount.ts +++ b/src/vs/base/common/defaultAccount.ts @@ -35,6 +35,7 @@ export interface IEntitlementsData extends ILegacyQuotaSnapshotData { readonly limited_user_reset_date?: string; // for Copilot Free readonly quota_reset_date?: string; // for all other Copilot SKUs readonly quota_reset_date_utc?: string; // for all other Copilot SKUs (includes time) + readonly token_based_billing?: boolean; readonly quota_snapshots?: { chat?: IQuotaSnapshotData; completions?: IQuotaSnapshotData; diff --git a/src/vs/editor/common/config/editorConfigurationSchema.ts b/src/vs/editor/common/config/editorConfigurationSchema.ts index 2a0641f8c969ab..255be2c4cbf70f 100644 --- a/src/vs/editor/common/config/editorConfigurationSchema.ts +++ b/src/vs/editor/common/config/editorConfigurationSchema.ts @@ -207,7 +207,8 @@ const editorConfiguration: IConfigurationNode = { 'diffEditor.renderSideBySide': { type: 'boolean', default: diffEditorDefaultOptions.renderSideBySide, - description: nls.localize('sideBySide', "Controls whether the diff editor shows the diff side by side or inline.") + description: nls.localize('sideBySide', "Controls whether the diff editor shows the diff side by side or inline."), + agentsWindow: { default: true }, }, 'diffEditor.renderSideBySideInlineBreakpoint': { type: 'number', @@ -217,17 +218,20 @@ const editorConfiguration: IConfigurationNode = { 'diffEditor.useInlineViewWhenSpaceIsLimited': { type: 'boolean', default: diffEditorDefaultOptions.useInlineViewWhenSpaceIsLimited, - description: nls.localize('useInlineViewWhenSpaceIsLimited', "If enabled and the editor width is too small, the inline view is used.") + description: nls.localize('useInlineViewWhenSpaceIsLimited', "If enabled and the editor width is too small, the inline view is used."), + agentsWindow: { default: true }, }, 'diffEditor.renderMarginRevertIcon': { type: 'boolean', default: diffEditorDefaultOptions.renderMarginRevertIcon, - description: nls.localize('renderMarginRevertIcon', "When enabled, the diff editor shows arrows in its glyph margin to revert changes.") + description: nls.localize('renderMarginRevertIcon', "When enabled, the diff editor shows arrows in its glyph margin to revert changes."), + agentsWindow: { default: false }, }, 'diffEditor.renderGutterMenu': { type: 'boolean', default: diffEditorDefaultOptions.renderGutterMenu, - description: nls.localize('renderGutterMenu', "When enabled, the diff editor shows a special gutter for revert and stage actions.") + description: nls.localize('renderGutterMenu', "When enabled, the diff editor shows a special gutter for revert and stage actions."), + agentsWindow: { default: false }, }, 'diffEditor.ignoreTrimWhitespace': { type: 'boolean', @@ -237,7 +241,8 @@ const editorConfiguration: IConfigurationNode = { 'diffEditor.renderIndicators': { type: 'boolean', default: diffEditorDefaultOptions.renderIndicators, - description: nls.localize('renderIndicators', "Controls whether the diff editor shows +/- indicators for added/removed changes.") + description: nls.localize('renderIndicators', "Controls whether the diff editor shows +/- indicators for added/removed changes."), + agentsWindow: { default: false }, }, 'diffEditor.codeLens': { type: 'boolean', @@ -267,6 +272,7 @@ const editorConfiguration: IConfigurationNode = { type: 'boolean', default: diffEditorDefaultOptions.hideUnchangedRegions.enabled, markdownDescription: nls.localize('hideUnchangedRegions.enabled', "Controls whether the diff editor shows unchanged regions."), + agentsWindow: { default: true }, }, 'diffEditor.hideUnchangedRegions.revealLineCount': { type: 'integer', diff --git a/src/vs/platform/agentHost/browser/nullAgentHostService.ts b/src/vs/platform/agentHost/browser/nullAgentHostService.ts index 184572bbb05041..673ea22be5041b 100644 --- a/src/vs/platform/agentHost/browser/nullAgentHostService.ts +++ b/src/vs/platform/agentHost/browser/nullAgentHostService.ts @@ -9,7 +9,7 @@ import { constObservable, IObservable } from '../../../base/common/observable.js import { URI } from '../../../base/common/uri.js'; import type { IAgentCreateSessionConfig, IAgentHostInspectInfo, IAgentHostService, IAgentHostSocketInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../common/agentService.js'; import type { IAgentSubscription } from '../common/state/agentSubscription.js'; -import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; +import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from '../common/state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult } from '../common/state/sessionProtocol.js'; import type { ComponentToState, RootState, StateComponents } from '../common/state/sessionState.js'; @@ -44,6 +44,7 @@ export class NullAgentHostService implements IAgentHostService { async createSession(_config?: IAgentCreateSessionConfig): Promise { return notSupported(); } async resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise { return notSupported(); } async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { return notSupported(); } + async completions(_params: CompletionsParams): Promise { return { items: [] }; } async startWebSocketServer(): Promise { return notSupported(); } async getInspectInfo(_tryEnable: boolean): Promise { return undefined; } async disposeSession(_session: URI): Promise { } diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 3e517516dc9008..03c02b69118710 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -28,7 +28,7 @@ import { PROTOCOL_VERSION } from '../common/state/protocol/version/registry.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, ProtocolError, type ProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { isClientTransport, type IProtocolTransport } from '../common/state/sessionTransport.js'; import { AhpErrorCodes } from '../common/state/protocol/errors.js'; -import { ContentEncoding, ResourceRequestParams, type CreateTerminalParams, type ResolveSessionConfigResult, type SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; +import { ContentEncoding, ResourceRequestParams, type CompletionsParams, type CompletionsResult, type CreateTerminalParams, type ResolveSessionConfigResult, type SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import { decodeBase64, encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; const AHP_CLIENT_CONNECTION_CLOSED = -32000; @@ -249,6 +249,10 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC }); } + async completions(params: CompletionsParams): Promise { + return this._sendRequest('completions', params); + } + /** * Authenticate with the remote agent host using a specific scheme. */ diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 578fe1a28c912a..64c2c80748fff0 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -11,7 +11,7 @@ import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { ISyncedCustomization } from './agentPluginManager.js'; import type { IAgentSubscription } from './state/agentSubscription.js'; -import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from './state/protocol/commands.js'; +import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from './state/protocol/commands.js'; import { ProtectedResourceMetadata, type ConfigSchema, type FileEdit, type ModelSelection, type SessionActiveClient, type ToolCallPendingConfirmationState, type ToolDefinition } from './state/protocol/state.js'; import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from './state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult, IStateSnapshot } from './state/sessionProtocol.js'; @@ -593,6 +593,14 @@ export interface IAgentService { /** Return dynamic completions for a session configuration property. */ sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise; + /** + * Return completion items for a partially-typed input (e.g. an `@`-mention + * inside a user message the user is composing). Delegates to a pluggable + * set of {@link IAgentHostCompletionItemProvider}s registered with the + * agent host. + */ + completions(params: CompletionsParams): Promise; + /** Dispose a session in the agent host, freeing SDK resources. */ disposeSession(session: URI): Promise; @@ -716,6 +724,7 @@ export interface IAgentConnection { createSession(config?: IAgentCreateSessionConfig): Promise; resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise; sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise; + completions(params: CompletionsParams): Promise; disposeSession(session: URI): Promise; // ---- Terminal lifecycle ------------------------------------------------- diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index c70e73834a427b..d849bba39d1a72 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -4551ca9 +41634da diff --git a/src/vs/platform/agentHost/common/state/protocol/commands.ts b/src/vs/platform/agentHost/common/state/protocol/commands.ts index 9a26bee1ca48db..9e52e0fd1d74e6 100644 --- a/src/vs/platform/agentHost/common/state/protocol/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/commands.ts @@ -6,7 +6,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -import type { URI, Snapshot, SessionConfigSchema, SessionSummary, ModelSelection, Turn, TerminalClaim, SessionActiveClient } from './state.js'; +import type { URI, Snapshot, SessionConfigSchema, SessionSummary, ModelSelection, Turn, TerminalClaim, SessionActiveClient, MessageAttachment } from './state.js'; import type { ActionEnvelope, StateAction } from './actions.js'; export type { ConfigPropertySchema, ConfigSchema, SessionConfigPropertySchema, SessionConfigSchema } from './state.js'; @@ -69,6 +69,13 @@ export interface InitializeResult { snapshots: Snapshot[]; /** Suggested default directory for remote filesystem browsing */ defaultDirectory?: URI; + /** + * Characters that, when typed in a {@link UserMessage} input, SHOULD cause + * the client to issue a `completions` request with + * {@link CompletionItemKind.UserMessage}. Typically includes characters like + * `'@'` or `'/'`. + */ + completionTriggerCharacters?: string[]; } // ─── reconnect ─────────────────────────────────────────────────────────────── @@ -927,3 +934,127 @@ export interface SessionConfigCompletionsResult { /** Matching value items */ items: SessionConfigValueItem[]; } + +// ─── completions ───────────────────────────────────────────────────────────── + +/** + * The kind of completion items being requested. + * + * @category Commands + */ +export const enum CompletionItemKind { + /** + * Completions for the text of a {@link UserMessage} the user is composing. + * Each returned item carries an attachment that gets associated with the + * message when accepted. + */ + UserMessage = 'userMessage', +} + +/** + * Requests completion items for a partially-typed input (e.g. a user message + * the user is currently composing). Used to power `@`-mention pickers, + * file/symbol references, and similar inline-completion experiences. + * + * Servers SHOULD treat this command as best-effort and return promptly. The + * client SHOULD debounce calls to avoid flooding the server with requests on + * every keystroke. + * + * @category Commands + * @method completions + * @direction Client → Server + * @messageType Request + * @version 1 + * @example + * ```jsonc + * // User has typed "look at @foo" and the cursor is just after "@foo". + * // Client → Server + * { "jsonrpc": "2.0", "id": 12, "method": "completions", + * "params": { "kind": "userMessage", "session": "copilot:/", + * "text": "look at @foo", "offset": 12 } } + * + * // Server → Client + * { "jsonrpc": "2.0", "id": 12, "result": { + * "items": [ + * { + * "insertText": "@foo.ts", + * "rangeStart": 8, + * "rangeEnd": 12, + * "attachment": { + * "type": "resource", + * "label": "foo.ts", + * "displayKind": "document", + * "uri": "file:///workspace/foo.ts" + * } + * } + * ] + * }} + * ``` + */ +export interface CompletionsParams { + /** What kind of completion is being requested. */ + kind: CompletionItemKind; + /** The session URI the completion is being requested for. */ + session: URI; + /** + * The complete text of the input being completed (e.g. the full user + * message text typed so far). + */ + text: string; + /** + * The character offset within `text` at which the completion is requested, + * measured in UTF-16 code units. MUST satisfy `0 <= offset <= text.length`. + */ + offset: number; +} + +/** + * A single completion item returned by the `completions` command. + * + * When the user accepts an item, the client SHOULD: + * 1. Replace the range `[rangeStart, rangeEnd)` in the input with `insertText` + * (or insert `insertText` at the cursor when the range is omitted). + * 2. Associate the item's `attachment` with the resulting {@link UserMessage}. + * + * @category Commands + */ +export interface CompletionItem { + /** + * The text inserted into the input when this item is accepted. + */ + insertText: string; + + /** + * If defined, the start of the range in the input's `text` that is replaced + * by `insertText`. The range is the half-open interval + * `[rangeStart, rangeEnd)` of character offsets, measured in UTF-16 code + * units. + * + * When omitted, the client SHOULD insert `insertText` at the cursor. + * + * Note: this range refers to positions in the *current* input. The + * attachment's own `rangeStart`/`rangeEnd` (when present) refer to + * positions in the final {@link UserMessage.text} after the item is + * accepted. + */ + rangeStart?: number; + + /** + * The end of the range in the input's `text` that is replaced by + * `insertText`. See {@link rangeStart}. + */ + rangeEnd?: number; + + /** + * The attachment associated with this completion item. + */ + attachment: MessageAttachment; +} + +/** + * Result of the `completions` command. + */ +export interface CompletionsResult { + /** The completion items, in the order the server suggests displaying them. */ + items: CompletionItem[]; +} diff --git a/src/vs/platform/agentHost/common/state/protocol/messages.ts b/src/vs/platform/agentHost/common/state/protocol/messages.ts index 4b1dda744433fb..6fe656b48d3a30 100644 --- a/src/vs/platform/agentHost/common/state/protocol/messages.ts +++ b/src/vs/platform/agentHost/common/state/protocol/messages.ts @@ -6,7 +6,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -import type { InitializeParams, InitializeResult, ReconnectParams, ReconnectResult, SubscribeParams, SubscribeResult, CreateSessionParams, DisposeSessionParams, CreateTerminalParams, DisposeTerminalParams, ListSessionsParams, ListSessionsResult, ResourceReadParams, ResourceReadResult, ResourceWriteParams, ResourceWriteResult, ResourceListParams, ResourceListResult, ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceMoveParams, ResourceMoveResult, ResourceRequestParams, ResourceRequestResult, FetchTurnsParams, FetchTurnsResult, UnsubscribeParams, DispatchActionParams, AuthenticateParams, AuthenticateResult, ResolveSessionConfigParams, ResolveSessionConfigResult, SessionConfigCompletionsParams, SessionConfigCompletionsResult } from './commands.js'; +import type { InitializeParams, InitializeResult, ReconnectParams, ReconnectResult, SubscribeParams, SubscribeResult, CreateSessionParams, DisposeSessionParams, CreateTerminalParams, DisposeTerminalParams, ListSessionsParams, ListSessionsResult, ResourceReadParams, ResourceReadResult, ResourceWriteParams, ResourceWriteResult, ResourceListParams, ResourceListResult, ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceMoveParams, ResourceMoveResult, ResourceRequestParams, ResourceRequestResult, FetchTurnsParams, FetchTurnsResult, UnsubscribeParams, DispatchActionParams, AuthenticateParams, AuthenticateResult, ResolveSessionConfigParams, ResolveSessionConfigResult, SessionConfigCompletionsParams, SessionConfigCompletionsResult, CompletionsParams, CompletionsResult } from './commands.js'; import type { ActionEnvelope } from './actions.js'; import type { ProtocolNotification } from './notifications.js'; @@ -92,6 +92,7 @@ export interface CommandMap { 'authenticate': { params: AuthenticateParams; result: AuthenticateResult }; 'resolveSessionConfig': { params: ResolveSessionConfigParams; result: ResolveSessionConfigResult }; 'sessionConfigCompletions': { params: SessionConfigCompletionsParams; result: SessionConfigCompletionsResult }; + 'completions': { params: CompletionsParams; result: CompletionsResult }; } /** diff --git a/src/vs/platform/agentHost/electron-browser/agentHostService.ts b/src/vs/platform/agentHost/electron-browser/agentHostService.ts index d426f2b2c0d2c2..ceaced587b6a34 100644 --- a/src/vs/platform/agentHost/electron-browser/agentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/agentHostService.ts @@ -16,7 +16,7 @@ import { IConfigurationService } from '../../configuration/common/configuration. import { ILogService } from '../../log/common/log.js'; import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentHostInspectInfo, IAgentHostService, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IAgentHostSocketInfo, IConnectionTrackerService } from '../common/agentService.js'; import { AgentSubscriptionManager, type IAgentSubscription } from '../common/state/agentSubscription.js'; -import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; +import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from '../common/state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult, IStateSnapshot } from '../common/state/sessionProtocol.js'; import { StateComponents, ROOT_STATE_URI, type RootState } from '../common/state/sessionState.js'; @@ -152,6 +152,9 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise { return this._proxy.sessionConfigCompletions(params); } + completions(params: CompletionsParams): Promise { + return this._proxy.completions(params); + } disposeSession(session: URI): Promise { return this._proxy.disposeSession(session); } diff --git a/src/vs/platform/agentHost/node/agentHostCompletions.ts b/src/vs/platform/agentHost/node/agentHostCompletions.ts new file mode 100644 index 00000000000000..8345e54e02fed5 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostCompletions.ts @@ -0,0 +1,94 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { ILogService } from '../../log/common/log.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import type { CompletionItem, CompletionItemKind, CompletionsParams, CompletionsResult } from '../common/state/protocol/commands.js'; + +export const IAgentHostCompletions = createDecorator('agentHostCompletions'); + +/** + * Pluggable provider that contributes {@link CompletionItem}s for one or + * more {@link CompletionItemKind}s. + * + * Providers are registered via {@link IAgentHostCompletions.registerProvider} + * and may be agent-specific (e.g. registered alongside an `IAgent`) or + * generic (e.g. the built-in workspace file completion provider). + */ +export interface IAgentHostCompletionItemProvider { + /** Completion kinds this provider handles. Providers are skipped for any other kind. */ + readonly kinds: ReadonlySet; + + /** + * Compute completion items for the given input. + * + * Implementations SHOULD respect `token` and return promptly. + * Throwing or rejecting fails this provider only; other providers' + * results are still returned by {@link IAgentHostCompletions.completions}. + */ + provideCompletionItems(params: CompletionsParams, token: CancellationToken): Promise; +} + +/** + * Server-side completions service. Owns a set of pluggable providers and + * fans out a single `completions` request to every provider whose + * {@link IAgentHostCompletionItemProvider.kinds} includes the requested kind. + * + * Provider results are concatenated in registration order; a single failing + * provider does not prevent other providers' results from being returned. + */ +export interface IAgentHostCompletions { + readonly _serviceBrand: undefined; + + /** + * Register a completion provider. The returned {@link IDisposable} unregisters + * the provider when disposed. + */ + registerProvider(provider: IAgentHostCompletionItemProvider): IDisposable; + + /** + * Compute completion items by fanning out to all matching providers. + */ + completions(params: CompletionsParams, token?: CancellationToken): Promise; +} + +export class AgentHostCompletions extends Disposable implements IAgentHostCompletions { + declare readonly _serviceBrand: undefined; + + private readonly _providers = new Set(); + + constructor( + @ILogService private readonly _logService: ILogService, + ) { + super(); + } + + registerProvider(provider: IAgentHostCompletionItemProvider): IDisposable { + this._providers.add(provider); + return toDisposable(() => this._providers.delete(provider)); + } + + async completions(params: CompletionsParams, token: CancellationToken = CancellationToken.None): Promise { + const matching = [...this._providers].filter(p => p.kinds.has(params.kind)); + if (matching.length === 0) { + return { items: [] }; + } + const settled = await Promise.allSettled( + matching.map(p => p.provideCompletionItems(params, token)), + ); + const items: CompletionItem[] = []; + for (let i = 0; i < settled.length; i++) { + const result = settled[i]; + if (result.status === 'fulfilled') { + items.push(...result.value); + } else { + this._logService.error(result.reason, `[AgentHostCompletions] Provider failed for kind=${params.kind}`); + } + } + return { items }; + } +} diff --git a/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts b/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts new file mode 100644 index 00000000000000..9dddf1ebd5019a --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../base/common/uri.js'; +import { CompletionItem, CompletionItemKind, CompletionsParams } from '../common/state/protocol/commands.js'; +import { IAgentHostCompletionItemProvider } from './agentHostCompletions.js'; +import { AgentHostStateManager } from './agentHostStateManager.js'; + +/** + * Generic completion provider that contributes workspace file references + * for a {@link CompletionItemKind.UserMessage} input — typically used for + * `@`-mentions in the user message composer. + * + * NOTE: This is currently a stub. The intended behaviour is: + * 1. Use {@link CompletionsParams.text} and {@link CompletionsParams.offset} + * to extract the `@`-prefixed token the user is typing. + * 2. List files under the session's workspace folder (resolved via the + * state manager) that match the token, respecting `.gitignore` and + * reasonable result limits. + * 3. Build {@link CompletionItem}s carrying a + * {@link MessageAttachmentKind.Resource} attachment pointing at the + * matched file URI. + */ +export class AgentHostFileCompletionProvider implements IAgentHostCompletionItemProvider { + + readonly kinds: ReadonlySet = new Set([CompletionItemKind.UserMessage]); + + constructor( + private readonly _stateManager: AgentHostStateManager, + ) { } + + async provideCompletionItems(params: CompletionsParams, _token: CancellationToken): Promise { + const workingDirectoryStr = this._stateManager.getSessionState(params.session)?.summary.workingDirectory; + if (!workingDirectoryStr) { + return []; + } + const workingDirectory = URI.parse(workingDirectoryStr); + // TODO: extract the `@`-token at params.offset, list files under + // `workingDirectory`, build CompletionItems with + // MessageAttachmentKind.Resource attachments. + void workingDirectory; + return []; + } +} diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 1fac09a2499b12..c3ac5758ca6cfb 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -6,6 +6,7 @@ import { RunOnceScheduler } from '../../../base/common/async.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; +import { equals } from '../../../base/common/objects.js'; import { ILogService } from '../../log/common/log.js'; import { ActionType, NotificationType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, RootAction, StateAction, TerminalAction, isRootAction, isSessionAction } from '../common/state/sessionActions.js'; import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; @@ -237,9 +238,16 @@ export class AgentHostStateManager extends Disposable { } /** - * Removes a session from in-memory state without emitting a notification. + * Removes a session from in-memory state without emitting a + * {@link NotificationType.SessionRemoved} notification. * Use {@link deleteSession} when the session is being permanently deleted - * and clients need to be notified. + * and clients need to be notified of its removal. + * + * Any pending summary change is flushed synchronously before the session is + * torn down, so clients receive the final status (e.g. Idle after a turn + * completes) even when the session is evicted before the scheduler fires. + * A {@link NotificationType.SessionSummaryChanged} notification may therefore + * be emitted as a side-effect of this call. */ removeSession(session: URI): void { const state = this._sessionStates.get(session); @@ -247,6 +255,13 @@ export class AgentHostStateManager extends Disposable { return; } + // Flush any pending summary notification before tearing down state so + // that the final status (e.g. Idle) reaches clients even if the session + // is evicted within the scheduler's debounce window. + if (this._dirtySummaries.has(session)) { + this._flushSummaryNotificationFor(session); + } + // Clean up active turn tracking if (state.activeTurn) { this._activeTurnToSession.delete(state.activeTurn.id); @@ -271,6 +286,10 @@ export class AgentHostStateManager extends Disposable { */ deleteSession(session: URI): void { const wasAnnounced = this._lastNotifiedSummaries.has(session); + // Drop any pending summary diff: the forthcoming SessionRemoved notification + // supersedes it and we don't want to emit spurious SessionSummaryChanged + // events just before the session disappears from the client's view. + this._dirtySummaries.delete(session); this.removeSession(session); if (wasAnnounced) { this._onDidEmitNotification.fire({ @@ -333,6 +352,22 @@ export class AgentHostStateManager extends Disposable { let resultingState: unknown = undefined; // Apply to state if (isRootAction(action)) { + // `RootConfigChanged` can be a true no-op: the reducer merges/replaces + // values even when the patch matches the current state, and re-emitting + // it would cause clients observing rootState.onDidChange to react and + // potentially re-dispatch in a loop. Check the action's own patch + // against current values before running the reducer so we avoid + // allocating a new state object at all. + if (action.type === ActionType.RootConfigChanged && this._rootState.config) { + const current = this._rootState.config.values; + const patch = action.config; + const isNoOp = action.replace + ? equals(current, patch) + : equals({ ...current, ...patch }, current); + if (isNoOp) { + return this._rootState; + } + } this._rootState = rootReducer(this._rootState, action as RootAction, this._log); resultingState = this._rootState; } @@ -385,33 +420,45 @@ export class AgentHostStateManager extends Disposable { private _flushSummaryNotifications(): void { for (const session of this._dirtySummaries) { - const state = this._sessionStates.get(session); - const lastNotified = this._lastNotifiedSummaries.get(session); - if (!state || !lastNotified || state.summary === lastNotified) { - continue; - } - - const current = state.summary; - const changes: Partial = {}; - if (current.title !== lastNotified.title) { changes.title = current.title; } - if (current.status !== lastNotified.status) { changes.status = current.status; } - if (current.activity !== lastNotified.activity) { changes.activity = current.activity; } - if (current.modifiedAt !== lastNotified.modifiedAt) { changes.modifiedAt = current.modifiedAt; } - if (current.project !== lastNotified.project) { changes.project = current.project; } - if (current.model !== lastNotified.model) { changes.model = current.model; } - if (current.workingDirectory !== lastNotified.workingDirectory) { changes.workingDirectory = current.workingDirectory; } - if (current.diffs !== lastNotified.diffs) { changes.diffs = current.diffs; } - - this._lastNotifiedSummaries.set(session, current); - - if (Object.keys(changes).length > 0) { - this._onDidEmitNotification.fire({ - type: NotificationType.SessionSummaryChanged, - session, - changes, - }); - } + this._flushSummaryNotificationFor(session); } this._dirtySummaries.clear(); } + + /** + * Emits a {@link NotificationType.SessionSummaryChanged} notification for + * `session` if its current summary differs from the last one sent to + * clients, then advances `_lastNotifiedSummaries` to the current summary. + * + * Does NOT remove `session` from `_dirtySummaries` — callers are + * responsible for that bookkeeping. + */ + private _flushSummaryNotificationFor(session: string): void { + const state = this._sessionStates.get(session); + const lastNotified = this._lastNotifiedSummaries.get(session); + if (!state || !lastNotified || state.summary === lastNotified) { + return; + } + + const current = state.summary; + const changes: Partial = {}; + if (current.title !== lastNotified.title) { changes.title = current.title; } + if (current.status !== lastNotified.status) { changes.status = current.status; } + if (current.activity !== lastNotified.activity) { changes.activity = current.activity; } + if (current.modifiedAt !== lastNotified.modifiedAt) { changes.modifiedAt = current.modifiedAt; } + if (current.project !== lastNotified.project) { changes.project = current.project; } + if (current.model !== lastNotified.model) { changes.model = current.model; } + if (current.workingDirectory !== lastNotified.workingDirectory) { changes.workingDirectory = current.workingDirectory; } + if (current.diffs !== lastNotified.diffs) { changes.diffs = current.diffs; } + + this._lastNotifiedSummaries.set(session, current); + + if (Object.keys(changes).length > 0) { + this._onDidEmitNotification.fire({ + type: NotificationType.SessionSummaryChanged, + session, + changes, + }); + } + } } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 0651701122d934..034e851e507fe8 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -21,7 +21,7 @@ import { ILogService } from '../../log/common/log.js'; import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentMaterializeSessionEvent, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../common/agentService.js'; import { ISessionDataService } from '../common/sessionDataService.js'; import { ActionType, ActionEnvelope, INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; -import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; +import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType, buildSubagentSessionUriPrefix, parseSubagentSessionUri, readSessionGitState, withSessionGitState, type SessionConfigState, type ISessionFileDiff, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { IProductService } from '../../product/common/productService.js'; @@ -32,6 +32,8 @@ import { ISessionDbUriFields, parseSessionDbUri } from './copilot/fileEditTracke import { IGitBlobUriFields, parseGitBlobUri } from './gitDiffContent.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { IAgentHostGitService } from './agentHostGitService.js'; +import { AgentHostCompletions, IAgentHostCompletions } from './agentHostCompletions.js'; +import { AgentHostFileCompletionProvider } from './agentHostFileCompletionProvider.js'; /** * Grace period before an empty, unsubscribed session is garbage-collected @@ -81,6 +83,8 @@ export class AgentService extends Disposable implements IAgentService { /** Manages PTY-backed terminals for the agent host protocol. */ private readonly _terminalManager: AgentHostTerminalManager; private readonly _configurationService: IAgentConfigurationService; + /** Pluggable completion item providers (e.g. workspace file completions, agent-specific @-mentions). */ + private readonly _completions: IAgentHostCompletions; /** * Authoritative server-side per-resource subscription refcount, keyed by @@ -131,6 +135,12 @@ export class AgentService extends Disposable implements IAgentService { ); const instantiationService = this._register(new InstantiationService(services, /*strict*/ true)); + this._completions = this._register(instantiationService.createInstance(AgentHostCompletions)); + // Built-in generic provider: completes files in the session's workspace folder. + this._register(this._completions.registerProvider( + new AgentHostFileCompletionProvider(this._stateManager), + )); + this._sideEffects = this._register(instantiationService.createInstance(AgentSideEffects, this._stateManager, { getAgent: session => this._findProviderForSession(session), sessionDataService: this._sessionDataService, @@ -507,6 +517,10 @@ export class AgentService extends Disposable implements IAgentService { return provider.sessionConfigCompletions(params); } + async completions(params: CompletionsParams): Promise { + return this._completions.completions(params); + } + async disposeSession(session: URI): Promise { this._logService.trace(`[AgentService] disposeSession: ${session.toString()}`); const provider = this._findProviderForSession(session); diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index cdfaf11005778f..d98d30796c783b 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -584,6 +584,9 @@ export class ProtocolServerHandler extends Disposable { query: params.query, }); }, + completions: async (_client, params) => { + return this._agentService.completions(params); + }, fetchTurns: async (_client, params) => { const state = this._stateManager.getSessionState(params.session); if (!state) { diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 64c0a5bf3e51ee..be2d65f0921fa8 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -128,6 +128,50 @@ suite('AgentHostStateManager', () => { assert.deepStrictEqual(envelopes[0].origin, origin); }); + test('root action that does not change state is not emitted', () => { + const envelopes: ActionEnvelope[] = []; + disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); + + // First dispatch: introduces a new value, should emit. + manager.dispatchServerAction({ + type: ActionType.RootConfigChanged, + config: { 'my.setting': 'value-a' }, + }); + assert.strictEqual(envelopes.length, 1); + assert.strictEqual(manager.serverSeq, 1); + + // Second dispatch with the same value: should be deduped and not emit. + manager.dispatchServerAction({ + type: ActionType.RootConfigChanged, + config: { 'my.setting': 'value-a' }, + }); + assert.strictEqual(envelopes.length, 1); + assert.strictEqual(manager.serverSeq, 1, 'serverSeq must not advance on a no-op'); + + // Third dispatch with a deeply-equal but newly allocated object value: + // should also be deduped. + manager.dispatchServerAction({ + type: ActionType.RootConfigChanged, + config: { 'my.nested': { allow: ['x'], deny: [] } }, + }); + assert.strictEqual(envelopes.length, 2); + assert.strictEqual(manager.serverSeq, 2); + manager.dispatchServerAction({ + type: ActionType.RootConfigChanged, + config: { 'my.nested': { allow: ['x'], deny: [] } }, + }); + assert.strictEqual(envelopes.length, 2); + assert.strictEqual(manager.serverSeq, 2, 'serverSeq must not advance on a no-op'); + + // Real change still emits. + manager.dispatchServerAction({ + type: ActionType.RootConfigChanged, + config: { 'my.setting': 'value-b' }, + }); + assert.strictEqual(envelopes.length, 3); + assert.strictEqual(manager.serverSeq, 3); + }); + test('removeSession clears state without notification', () => { manager.createSession(makeSessionSummary()); @@ -382,6 +426,51 @@ suite('AgentHostStateManager', () => { assert.strictEqual(changed.length, 0, 'should not emit for deleted sessions'); }); }); + + test('removeSession flushes pending status=Idle notification before eviction', () => { + // Regression: when _maybeEvictIdleSession calls removeSession within the + // 100 ms scheduler window after a turn completes, the client must still + // receive a SessionSummaryChanged with status=Idle so the spinner clears. + // + // The key precondition is that _lastNotifiedSummaries already has + // status=InProgress (the scheduler must have fired after TurnStarted so + // the client knows the session is busy). Then TurnComplete flips the + // summary back to Idle and schedules another flush. If removeSession + // races with that 100 ms window the flush must happen synchronously. + return runWithFakedTimers({ useFakeTimers: true }, async () => { + manager.createSession(makeSessionSummary()); + manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + + // Start a turn → status becomes InProgress. + manager.dispatchServerAction({ + type: ActionType.SessionTurnStarted, + session: sessionUri, + turnId: 'turn-1', + userMessage: { text: 'hello' }, + }); + + // Let the scheduler fire so _lastNotifiedSummaries now has status=InProgress. + await new Promise(r => setTimeout(r, 150)); + + const notifications: INotification[] = []; + disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); + + // Turn completes — status flips back to Idle. This schedules a summary + // flush 100 ms later but we will call removeSession before it fires. + manager.dispatchServerAction({ + type: ActionType.SessionTurnComplete, + session: sessionUri, + turnId: 'turn-1', + }); + + // Simulate eviction within the 100 ms debounce window. + manager.removeSession(sessionUri); + + const changed = notifications.filter(n => n.type === NotificationType.SessionSummaryChanged) as SessionSummaryChangedNotification[]; + assert.strictEqual(changed.length, 1, 'should emit SessionSummaryChanged synchronously in removeSession'); + assert.strictEqual(changed[0].changes.status, SessionStatus.Idle, 'status should be Idle so the spinner clears'); + }); + }); }); suite('Subagent URI helpers', () => { diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 7c1ccc4a0a2ee6..4dd37d35ba1bc0 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -11,7 +11,7 @@ import { runWithFakedTimers } from '../../../../base/test/common/timeTravelSched import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentService, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agentService.js'; -import { ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; +import { CompletionsParams, CompletionsResult, ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { ActionType, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, AHP_UNSUPPORTED_PROTOCOL_VERSION, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; @@ -110,6 +110,7 @@ class MockAgentService implements IAgentService { async resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise { return { schema: { type: 'object', properties: {} }, values: {} }; } async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { return { items: [] }; } + async completions(_params: CompletionsParams): Promise { return { items: [] }; } async disposeSession(_session: URI): Promise { } async listSessions(): Promise { return this.listedSessions; } async subscribe(resource: URI, _clientId: string): Promise { diff --git a/src/vs/platform/configuration/common/configurationRegistry.ts b/src/vs/platform/configuration/common/configurationRegistry.ts index a212c47ef1bb2b..5385cfbcecce4b 100644 --- a/src/vs/platform/configuration/common/configurationRegistry.ts +++ b/src/vs/platform/configuration/common/configurationRegistry.ts @@ -240,6 +240,22 @@ export interface IConfigurationPropertySchema extends IJSONSchema { */ name?: string; }; + + /** + * When specified, provides configuration overrides for the Agents window. + */ + agentsWindow?: { + /** + * Override default value for this setting in the Agents window. + */ + default?: unknown; + + /** + * When `true`, this setting is read-only in the Agents window + * and cannot be changed by the user. + */ + readOnly?: boolean; + }; } export interface IExtensionInfo { diff --git a/src/vs/platform/configuration/common/configurations.ts b/src/vs/platform/configuration/common/configurations.ts index 887aadae5ce52c..276d919e76a9d1 100644 --- a/src/vs/platform/configuration/common/configurations.ts +++ b/src/vs/platform/configuration/common/configurations.ts @@ -67,13 +67,17 @@ export class DefaultConfiguration extends Disposable { if (defaultOverrideValue !== undefined) { this._configurationModel.setValue(key, defaultOverrideValue); } else if (propertySchema) { - this._configurationModel.setValue(key, deepClone(propertySchema.default)); + this._configurationModel.setValue(key, this.getDefaultValue(key, propertySchema)); } else { this._configurationModel.removeValue(key); } } } + protected getDefaultValue(_key: string, propertySchema: IRegisteredConfigurationPropertySchema): unknown { + return deepClone(propertySchema.default); + } + } export interface IPolicyConfiguration { diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 510ab589dc292e..ff9440d907a9b4 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -12,6 +12,9 @@ const _allApiProposals = { agentSessionsWorkspace: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.agentSessionsWorkspace.d.ts', }, + agentsWindowConfiguration: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.agentsWindowConfiguration.d.ts', + }, aiRelatedInformation: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.aiRelatedInformation.d.ts', }, diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index ff14e754a0d17b..d255fb14f3aad1 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -5,7 +5,7 @@ import { VSBuffer } from '../../../base/common/buffer.js'; import { Event } from '../../../base/common/event.js'; -import { URI } from '../../../base/common/uri.js'; +import { URI, UriComponents } from '../../../base/common/uri.js'; import { MessageBoxOptions, MessageBoxReturnValue, OpenDevToolsOptions, OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from '../../../base/parts/sandbox/common/electronTypes.js'; import { ISerializableCommandAction } from '../../action/common/action.js'; import { INativeOpenDialogOptions } from '../../dialogs/common/dialogs.js'; @@ -129,7 +129,7 @@ export interface ICommonNativeHostService { openWindow(options?: IOpenEmptyWindowOptions): Promise; openWindow(toOpen: IWindowOpenable[], options?: IOpenWindowOptions): Promise; - openAgentsWindow(): Promise; + openAgentsWindow(options?: { folderUri?: UriComponents }): Promise; /** * Launches the sibling application (host ↔ embedded). diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index 959ebdd602bcfc..1cfa2a170cb2cc 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -15,7 +15,7 @@ import { matchesSomeScheme, Schemas } from '../../../base/common/network.js'; import { dirname, join, posix, resolve, win32 } from '../../../base/common/path.js'; import { isLinux, isMacintosh, isWindows } from '../../../base/common/platform.js'; import { AddFirstParameterToFunctions } from '../../../base/common/types.js'; -import { URI } from '../../../base/common/uri.js'; +import { URI, UriComponents } from '../../../base/common/uri.js'; import { virtualMachineHint } from '../../../base/node/id.js'; import { Promises, SymlinkSupport } from '../../../base/node/pfs.js'; import { launchSiblingApp } from '../node/siblingApp.js'; @@ -305,12 +305,15 @@ export class NativeHostMainService extends Disposable implements INativeHostMain }, options); } - async openAgentsWindow(windowId: number | undefined): Promise { - await this.windowsMainService.openAgentsWindow({ + async openAgentsWindow(windowId: number | undefined, options?: { folderUri?: UriComponents }): Promise { + const windows = await this.windowsMainService.openAgentsWindow({ context: OpenContext.API, contextWindowId: windowId, cli: this.environmentMainService.args, - }); + }, options?.folderUri ? URI.revive(options.folderUri) : undefined); + if (windows.length > 0) { + windows[0].focus(); + } } async launchSiblingApp(_windowId: number | undefined, args?: string[]): Promise { diff --git a/src/vs/platform/update/common/update.config.contribution.ts b/src/vs/platform/update/common/update.config.contribution.ts index 8507c95c6aae47..2c63e9e679cfb6 100644 --- a/src/vs/platform/update/common/update.config.contribution.ts +++ b/src/vs/platform/update/common/update.config.contribution.ts @@ -76,7 +76,8 @@ configurationRegistry.registerConfiguration({ default: true, scope: ConfigurationScope.APPLICATION, description: localize('showReleaseNotes', "Show Release Notes after an update. The Release Notes are fetched from a Microsoft online service."), - tags: ['usesOnlineServices'] + tags: ['usesOnlineServices'], + agentsWindow: { default: false, readOnly: true }, }, 'update.showPostInstallInfo': { type: 'boolean', diff --git a/src/vs/platform/windows/electron-main/windows.ts b/src/vs/platform/windows/electron-main/windows.ts index 0bfb8ccfa2ca71..3f6af0feb38f73 100644 --- a/src/vs/platform/windows/electron-main/windows.ts +++ b/src/vs/platform/windows/electron-main/windows.ts @@ -41,7 +41,7 @@ export interface IWindowsMainService { openExtensionDevelopmentHostWindow(extensionDevelopmentPath: string[], openConfig: IOpenConfiguration): Promise; openExistingWindow(window: ICodeWindow, openConfig: IOpenConfiguration): void; - openAgentsWindow(openConfig: IOpenConfiguration): Promise; + openAgentsWindow(openConfig: IOpenConfiguration, folderUri?: URI): Promise; sendToFocused(channel: string, ...args: unknown[]): void; sendToOpeningWindow(channel: string, ...args: unknown[]): void; diff --git a/src/vs/platform/windows/electron-main/windowsMainService.ts b/src/vs/platform/windows/electron-main/windowsMainService.ts index 9bd68e3d2df7d1..e9072892638d7e 100644 --- a/src/vs/platform/windows/electron-main/windowsMainService.ts +++ b/src/vs/platform/windows/electron-main/windowsMainService.ts @@ -292,11 +292,18 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic this.handleChatRequest(openConfig, [window]); } - async openAgentsWindow(openConfig: IOpenConfiguration): Promise { + async openAgentsWindow(openConfig: IOpenConfiguration, folderUri?: URI): Promise { this.logService.trace('windowsManager#openAgentsWindow'); // Open in a new browser window with the agent sessions workspace - return this.open(await this.ensureAgentsWindow(openConfig)); + const windows = await this.open(await this.ensureAgentsWindow(openConfig)); + + // Tell the agents window to select the given folder in the new chat workspace picker + if (folderUri && windows.length > 0) { + windows[0].sendWhenReady('vscode:selectAgentsFolder', CancellationToken.None, folderUri.toJSON()); + } + + return windows; } private async ensureAgentsWindow(openConfig: IOpenConfiguration): Promise { diff --git a/src/vs/sessions/browser/parts/media/panelPart.css b/src/vs/sessions/browser/parts/media/panelPart.css index 48e33b8d3c7007..88a9d4a66f0c21 100644 --- a/src/vs/sessions/browser/parts/media/panelPart.css +++ b/src/vs/sessions/browser/parts/media/panelPart.css @@ -62,6 +62,10 @@ background: var(--vscode-agentsPanel-background) !important; } +.agent-sessions-workbench .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .badge.compact .badge-content { + top: 11px; +} + /* Terminal body background */ .agent-sessions-workbench .part.panel .terminal-wrapper { background-color: var(--vscode-agentsPanel-background); diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index 4298334831b16c..bd1a0254fe8cf3 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -56,6 +56,7 @@ import { NotificationsCenter } from '../../workbench/browser/parts/notifications import { NotificationsAlerts } from '../../workbench/browser/parts/notifications/notificationsAlerts.js'; import { NotificationsStatus } from '../../workbench/browser/parts/notifications/notificationsStatus.js'; import { registerNotificationCommands } from '../../workbench/browser/parts/notifications/notificationsCommands.js'; +import { CommandsRegistry } from '../../platform/commands/common/commands.js'; import { NotificationsToasts } from '../../workbench/browser/parts/notifications/notificationsToasts.js'; import { IMarkdownRendererService } from '../../platform/markdown/browser/markdownRenderer.js'; import { EditorMarkdownCodeBlockRenderer } from '../../editor/browser/widget/markdownRenderer/browser/editorMarkdownCodeBlockRenderer.js'; @@ -123,6 +124,8 @@ export interface IAgentWorkbenchLayoutService extends IWorkbenchLayoutService { export const IAgentWorkbenchLayoutService = refineServiceDecorator(IWorkbenchLayoutService); +export const CLOSE_MOBILE_SIDEBAR_DRAWER_COMMAND_ID = 'sessions.closeMobileSidebarDrawer'; + export class Workbench extends Disposable implements IAgentWorkbenchLayoutService { declare readonly _serviceBrand: undefined; @@ -518,6 +521,15 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic } private registerListeners(lifecycleService: ILifecycleService, storageService: IStorageService, configurationService: IConfigurationService, hostService: IHostService, dialogService: IDialogService): void { + // Command: close the mobile sidebar drawer (no-op outside phone layout). + // Routes through the proper close path so the mobile nav/history stack + // stays in sync (avoids extra Android back-button presses). + this._register(CommandsRegistry.registerCommand(CLOSE_MOBILE_SIDEBAR_DRAWER_COMMAND_ID, () => { + if (this.layoutPolicy.viewportClass.get() === 'phone') { + this.closeMobileSidebarDrawer(); + } + })); + // Configuration changes this._register(configurationService.onDidChangeConfiguration(e => this.updateFontAliasing(e, configurationService))); @@ -678,9 +690,12 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.toggleMobileSidebarDrawer(); })); - // New session: open new chat view + // New session: open new chat view and dismiss the sidebar drawer + // so the new session view becomes visible. createMobileTitlebar() is + // only invoked in phone layout, so closing the drawer here is safe. this.mobileTopBarDisposables.add(mobileTitlebar.onDidClickNewSession(() => { this.sessionsManagementService.openNewSessionView(); + this.closeMobileSidebarDrawer(); })); } diff --git a/src/vs/sessions/contrib/agentHost/browser/openSessionEventsFileActions.ts b/src/vs/sessions/contrib/agentHost/browser/openSessionEventsFileActions.ts new file mode 100644 index 00000000000000..b1a9b05ce52429 --- /dev/null +++ b/src/vs/sessions/contrib/agentHost/browser/openSessionEventsFileActions.ts @@ -0,0 +1,175 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { joinPath } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { agentHostAuthority, toAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js'; +import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { IsAgentHostSession } from './agentHostSkillButtons.js'; +import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; +import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; + +// Scheme conventions for `copilotcli` chat sessions: +// - Local AH: `agent-host-copilotcli:/` (LOCAL_RESOURCE_SCHEME_PREFIX + provider) +// - Remote AH: `remote--copilotcli:/` (remoteAgentHostSessionTypeId) +// - EH CLI ext: `copilotcli:/` (extension's own session type) +const COPILOT_CLI_PROVIDER = 'copilotcli'; +const LOCAL_AH_SCHEME = `agent-host-${COPILOT_CLI_PROVIDER}`; +const EH_CLI_SCHEME = COPILOT_CLI_PROVIDER; +const REMOTE_PROVIDER_PREFIX = 'remote-'; +const REMOTE_PROVIDER_SUFFIX = `-${COPILOT_CLI_PROVIDER}`; + +/** + * Builds the local `events.jsonl` URI under `~/.copilot/session-state//`. + * + * Used for both the local Agent Host Copilot CLI provider and the + * extension-host Copilot CLI provider, which share the same on-disk layout + * and the same chat session URI shape (`copilotcli:/`). + */ +export function buildLocalEventsUri(userHome: URI, rawSessionId: string): URI { + return joinPath(userHome, '.copilot', 'session-state', rawSessionId, 'events.jsonl'); +} + +/** + * Builds a `vscode-agent-host://` URI for the `events.jsonl` file inside + * the host's user home directory, using the connection's reported + * `defaultDirectory` (set from `os.homedir()` on the host during the + * AHP handshake). + * + * The path is joined at the URI-path level using forward slashes, which + * works for both POSIX hosts (`/home/me`) and Windows hosts whose + * `URI.file(os.homedir()).path` is also forward-slash-rooted (e.g. + * `/c:/Users/me`). Returns `undefined` if the host did not report a + * usable `defaultDirectory`. + */ +export function buildRemoteEventsUri(connection: IRemoteAgentHostConnectionInfo, rawSessionId: string): URI | undefined { + const homePath = connection.defaultDirectory; + if (!homePath) { + return undefined; + } + const trimmed = homePath.endsWith('/') ? homePath.slice(0, -1) : homePath; + const remoteFileUri = URI.from({ + scheme: 'file', + path: `${trimmed}/.copilot/session-state/${rawSessionId}/events.jsonl`, + }); + const authority = agentHostAuthority(connection.address); + return toAgentHostUri(remoteFileUri, authority); +} + +/** + * Parses the connection authority out of a remote AH chat session scheme + * of the form `remote--copilotcli`. Returns `undefined` for + * any other scheme, including the local `copilotcli` scheme. + */ +export function parseRemoteAuthorityFromScheme(scheme: string): string | undefined { + if (!scheme.startsWith(REMOTE_PROVIDER_PREFIX) || !scheme.endsWith(REMOTE_PROVIDER_SUFFIX)) { + return undefined; + } + const authority = scheme.slice(REMOTE_PROVIDER_PREFIX.length, scheme.length - REMOTE_PROVIDER_SUFFIX.length); + return authority || undefined; +} + +export type ResolveEventsUriResult = + | { readonly kind: 'ok'; readonly resource: URI } + | { readonly kind: 'no-session' } + | { readonly kind: 'unsupported-scheme'; readonly scheme: string } + | { readonly kind: 'remote-not-connected'; readonly authority: string } + | { readonly kind: 'remote-no-home'; readonly authority: string }; + +/** + * Pure resolver for tests. Translates a chat session resource into the + * `events.jsonl` URI for the corresponding Copilot CLI session, or + * returns a structured error. + */ +export function resolveEventsUri( + sessionResource: URI | undefined, + userHome: URI, + getConnectionByAuthority: (authority: string) => IRemoteAgentHostConnectionInfo | undefined, +): ResolveEventsUriResult { + if (!sessionResource) { + return { kind: 'no-session' }; + } + const rawId = sessionResource.path.startsWith('/') ? sessionResource.path.substring(1) : sessionResource.path; + if (!rawId) { + return { kind: 'no-session' }; + } + + if (sessionResource.scheme === LOCAL_AH_SCHEME || sessionResource.scheme === EH_CLI_SCHEME) { + return { kind: 'ok', resource: buildLocalEventsUri(userHome, rawId) }; + } + + const remoteAuthority = parseRemoteAuthorityFromScheme(sessionResource.scheme); + if (remoteAuthority) { + const connection = getConnectionByAuthority(remoteAuthority); + if (!connection) { + return { kind: 'remote-not-connected', authority: remoteAuthority }; + } + const resource = buildRemoteEventsUri(connection, rawId); + if (!resource) { + return { kind: 'remote-no-home', authority: remoteAuthority }; + } + return { kind: 'ok', resource }; + } + + return { kind: 'unsupported-scheme', scheme: sessionResource.scheme }; +} + +export class OpenSessionEventsFileAction extends Action2 { + + static readonly ID = 'agentHost.openSessionEventsFile'; + + constructor() { + super({ + id: OpenSessionEventsFileAction.ID, + title: localize2('openSessionEventsFile', "Open Copilot CLI State File"), + f1: true, + category: Categories.Developer, + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, IsAgentHostSession), + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const sessionsManagementService = accessor.get(ISessionsManagementService); + const pathService = accessor.get(IPathService); + const remoteAgentHostService = accessor.get(IRemoteAgentHostService); + const editorService = accessor.get(IEditorService); + const notificationService = accessor.get(INotificationService); + + const sessionResource = sessionsManagementService.activeSession.get()?.resource; + const userHome = pathService.userHome({ preferLocal: true }); + + const result = resolveEventsUri( + sessionResource, + userHome, + authority => remoteAgentHostService.connections.find(c => agentHostAuthority(c.address) === authority), + ); + + switch (result.kind) { + case 'ok': + await editorService.openEditor({ resource: result.resource }); + return; + case 'no-session': + notificationService.info(localize('openSessionEventsFile.noSession', "No Copilot CLI session is active.")); + return; + case 'unsupported-scheme': + notificationService.info(localize('openSessionEventsFile.unsupported', "The active chat session is not a Copilot CLI session.")); + return; + case 'remote-not-connected': + notificationService.warn(localize('openSessionEventsFile.notConnected', "No active connection found for remote agent host '{0}'.", result.authority)); + return; + case 'remote-no-home': + notificationService.warn(localize('openSessionEventsFile.noHome', "Remote agent host '{0}' did not report a home directory.", result.authority)); + return; + } + } +} diff --git a/src/vs/sessions/contrib/agentHost/electron-browser/collectDebugLogsAction.ts b/src/vs/sessions/contrib/agentHost/electron-browser/collectDebugLogsAction.ts new file mode 100644 index 00000000000000..2f7d99efb18705 --- /dev/null +++ b/src/vs/sessions/contrib/agentHost/electron-browser/collectDebugLogsAction.ts @@ -0,0 +1,169 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { joinPath } from '../../../../base/common/resources.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { agentHostAuthority } from '../../../../platform/agentHost/common/agentHostUri.js'; +import { IAgentHostService } from '../../../../platform/agentHost/common/agentService.js'; +import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { INativeHostService } from '../../../../platform/native/common/native.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; +import { IOutputService } from '../../../../workbench/services/output/common/output.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { IsAgentHostSession } from '../browser/agentHostSkillButtons.js'; +import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; +import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; +import { resolveEventsUri, parseRemoteAuthorityFromScheme } from '../browser/openSessionEventsFileActions.js'; + +/** Output channel ID for the agent host process logger (forwarded via RemoteLoggerChannelClient). */ +const AGENT_HOST_LOGGER_CHANNEL_ID = 'agenthost'; +/** Output channel ID for the current window's renderer log. */ +const WINDOW_LOG_CHANNEL_ID = 'rendererLog'; +/** Output channel ID for the shared process compound log. */ +const SHARED_PROCESS_LOG_CHANNEL_ID = 'shared'; + +const LOCAL_AH_SCHEME = 'agent-host-copilotcli'; +const EH_CLI_SCHEME = 'copilotcli'; + +export class CollectAgentHostDebugLogsAction extends Action2 { + + static readonly ID = 'agentHost.collectDebugLogs'; + + constructor() { + super({ + id: CollectAgentHostDebugLogsAction.ID, + title: localize2('collectAgentHostDebugLogs', "Collect Agent Host Debug Logs"), + f1: true, + category: Categories.Developer, + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, IsAgentHostSession), + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const sessionsManagementService = accessor.get(ISessionsManagementService); + const pathService = accessor.get(IPathService); + const remoteAgentHostService = accessor.get(IRemoteAgentHostService); + const agentHostService = accessor.get(IAgentHostService); + const outputService = accessor.get(IOutputService); + const fileService = accessor.get(IFileService); + const fileDialogService = accessor.get(IFileDialogService); + const nativeHostService = accessor.get(INativeHostService); + const notificationService = accessor.get(INotificationService); + const textModelService = accessor.get(ITextModelService); + + const sessionResource = sessionsManagementService.activeSession.get()?.resource; + const userHome = pathService.userHome({ preferLocal: true }); + + const eventsResult = resolveEventsUri( + sessionResource, + userHome, + authority => remoteAgentHostService.connections.find(c => agentHostAuthority(c.address) === authority), + ); + + switch (eventsResult.kind) { + case 'no-session': + notificationService.info(localize('collectDebugLogs.noSession', "No Copilot CLI session is active.")); + return; + case 'unsupported-scheme': + notificationService.info(localize('collectDebugLogs.unsupported', "The active chat session is not a Copilot CLI session.")); + return; + case 'remote-not-connected': + notificationService.warn(localize('collectDebugLogs.notConnected', "No active connection found for remote agent host '{0}'.", eventsResult.authority)); + return; + case 'remote-no-home': + notificationService.warn(localize('collectDebugLogs.noHome', "Remote agent host '{0}' did not report a home directory.", eventsResult.authority)); + return; + } + + const isLocal = sessionResource?.scheme === LOCAL_AH_SCHEME || sessionResource?.scheme === EH_CLI_SCHEME; + + // Collect all output channel IDs relevant for the current session's agent host. + const channelIds: string[] = []; + + if (isLocal) { + // IPC traffic log for the local agent host connection + channelIds.push(`agenthost.${agentHostService.clientId}`); + // Agent host process logger (forwarded from the utility process) + channelIds.push(AGENT_HOST_LOGGER_CHANNEL_ID); + } else { + const remoteAuthority = parseRemoteAuthorityFromScheme(sessionResource!.scheme); + if (remoteAuthority) { + const connection = remoteAgentHostService.connections.find(c => agentHostAuthority(c.address) === remoteAuthority); + if (connection) { + channelIds.push(`agenthost.${connection.clientId}`); + } + } + } + + // Always include the window and shared process logs + channelIds.push(WINDOW_LOG_CHANNEL_ID); + channelIds.push(SHARED_PROCESS_LOG_CHANNEL_ID); + + const files: { path: string; contents: string }[] = []; + + // 1. events.jsonl + try { + const content = await fileService.readFile(eventsResult.resource); + files.push({ path: 'events.jsonl', contents: content.value.toString() }); + } catch { + // File may not exist yet if the session never wrote any events + } + + // 2. Output channels + for (const channelId of channelIds) { + const channel = outputService.getChannel(channelId); + const descriptor = outputService.getChannelDescriptor(channelId); + if (!channel || !descriptor) { + continue; + } + const modelRef = await textModelService.createModelReference(channel.uri); + try { + const filename = `${descriptor.label.replace(/[/\\:*?"<>|]/g, '-')}.log`; + files.push({ path: filename, contents: modelRef.object.textEditorModel.getValue() }); + } finally { + modelRef.dispose(); + } + } + + if (files.length === 0) { + notificationService.notify({ + severity: Severity.Warning, + message: localize('collectDebugLogs.noFiles', "No log files were found for the active session."), + }); + return; + } + + const sessionTitle = sessionsManagementService.activeSession.get()?.title.get(); + const titleSlug = sessionTitle + ? `-${sessionTitle.replace(/[/\\:*?"<>|\s]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)}` + : ''; + const defaultUri = joinPath(await fileDialogService.defaultFilePath(), `ah-logs${titleSlug}.zip`); + const saveUri = await fileDialogService.showSaveDialog({ + title: localize('collectDebugLogs.saveDialogTitle', "Save Agent Host Debug Logs"), + defaultUri, + filters: [{ name: localize('collectDebugLogs.zipFilter', "Zip Archive"), extensions: ['zip'] }], + }); + + if (!saveUri) { + return; + } + + try { + await nativeHostService.createZipFile(saveUri, files); + } catch (error) { + notificationService.notify({ + severity: Severity.Error, + message: localize('collectDebugLogs.saveError', "Failed to save debug logs: {0}", error instanceof Error ? error.message : String(error)), + }); + } + } +} diff --git a/src/vs/sessions/contrib/agentHost/test/browser/openSessionEventsFile.test.ts b/src/vs/sessions/contrib/agentHost/test/browser/openSessionEventsFile.test.ts new file mode 100644 index 00000000000000..45e317304d21ce --- /dev/null +++ b/src/vs/sessions/contrib/agentHost/test/browser/openSessionEventsFile.test.ts @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IRemoteAgentHostConnectionInfo, RemoteAgentHostConnectionStatus } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { resolveEventsUri } from '../../browser/openSessionEventsFileActions.js'; + +suite('openSessionEventsFile resolveEventsUri', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const userHome = URI.file('/home/me'); + + function makeRemoteConn(address: string, defaultDirectory: string | undefined): IRemoteAgentHostConnectionInfo { + return { + address, + name: address, + clientId: 'client-1', + defaultDirectory, + status: RemoteAgentHostConnectionStatus.connected, + }; + } + + test('local AH copilotcli session resolves to ~/.copilot/session-state//events.jsonl', () => { + const result = resolveEventsUri(URI.parse('agent-host-copilotcli:/abc'), userHome, () => undefined); + assert.deepStrictEqual( + { kind: result.kind, resource: result.kind === 'ok' ? result.resource.toString() : undefined }, + { kind: 'ok', resource: 'file:///home/me/.copilot/session-state/abc/events.jsonl' }, + ); + }); + + test('EH CLI copilotcli session resolves to ~/.copilot/session-state//events.jsonl', () => { + const result = resolveEventsUri(URI.parse('copilotcli:/abc'), userHome, () => undefined); + assert.deepStrictEqual( + { kind: result.kind, resource: result.kind === 'ok' ? result.resource.toString() : undefined }, + { kind: 'ok', resource: 'file:///home/me/.copilot/session-state/abc/events.jsonl' }, + ); + }); + + test('remote copilotcli session wraps host events.jsonl in vscode-agent-host URI', () => { + const conn = makeRemoteConn('localhost:4321', '/home/remote'); + const result = resolveEventsUri( + URI.parse('remote-localhost__4321-copilotcli:/xyz'), + userHome, + authority => authority === 'localhost__4321' ? conn : undefined, + ); + assert.deepStrictEqual( + { kind: result.kind, resource: result.kind === 'ok' ? result.resource.toString() : undefined }, + { kind: 'ok', resource: 'vscode-agent-host://localhost__4321/file/-/home/remote/.copilot/session-state/xyz/events.jsonl' }, + ); + }); + + test('remote scheme without an active connection returns remote-not-connected', () => { + const result = resolveEventsUri( + URI.parse('remote-myhost-copilotcli:/abc'), + userHome, + () => undefined, + ); + assert.deepStrictEqual(result, { kind: 'remote-not-connected', authority: 'myhost' }); + }); + + test('remote scheme without a defaultDirectory returns remote-no-home', () => { + const conn = makeRemoteConn('myhost', undefined); + const result = resolveEventsUri( + URI.parse('remote-myhost-copilotcli:/abc'), + userHome, + authority => authority === 'myhost' ? conn : undefined, + ); + assert.deepStrictEqual(result, { kind: 'remote-no-home', authority: 'myhost' }); + }); + + test('unknown scheme returns unsupported-scheme', () => { + const result = resolveEventsUri(URI.parse('claude:/abc'), userHome, () => undefined); + assert.deepStrictEqual(result, { kind: 'unsupported-scheme', scheme: 'claude' }); + }); + + test('missing session resource returns no-session', () => { + const result = resolveEventsUri(undefined, userHome, () => undefined); + assert.deepStrictEqual(result, { kind: 'no-session' }); + }); +}); diff --git a/src/vs/sessions/contrib/chat/browser/mobileSessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/mobileSessionTypePicker.ts index 0b579a429083e5..b878d6e668a845 100644 --- a/src/vs/sessions/contrib/chat/browser/mobileSessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/mobileSessionTypePicker.ts @@ -5,10 +5,11 @@ import { localize } from '../../../../nls.js'; import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; -import { SessionTypePicker } from './sessionTypePicker.js'; +import { SessionTypePicker, STORAGE_KEY_LAST_SESSION_TYPE } from './sessionTypePicker.js'; import { isPhoneLayout } from '../../../browser/parts/mobile/mobileLayout.js'; import { IMobilePickerSheetItem, showMobilePickerSheet } from '../../../browser/parts/mobile/mobilePickerSheet.js'; @@ -29,9 +30,10 @@ export class MobileSessionTypePicker extends SessionTypePicker { @IActionWidgetService actionWidgetService: IActionWidgetService, @ISessionsManagementService sessionsManagementService: ISessionsManagementService, @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + @IStorageService storageService: IStorageService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, ) { - super(actionWidgetService, sessionsManagementService, sessionsProvidersService); + super(actionWidgetService, sessionsManagementService, sessionsProvidersService, storageService); } override render(container: HTMLElement): void { @@ -76,6 +78,7 @@ export class MobileSessionTypePicker extends SessionTypePicker { trigger.setAttribute('aria-expanded', 'false'); trigger.focus(); if (id !== undefined && id !== this._sessionType) { + this.storageService.store(STORAGE_KEY_LAST_SESSION_TYPE, id, StorageScope.PROFILE, StorageTarget.MACHINE); this._onDidSelectSessionType.fire(id); } }); diff --git a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts index 8335139f1efdc4..41c429095959d7 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts @@ -16,6 +16,9 @@ import { ISessionsProvidersService } from '../../../services/sessions/browser/se import { autorun } from '../../../../base/common/observable.js'; import { ISession, ISessionType } from '../../../services/sessions/common/session.js'; import { Emitter } from '../../../../base/common/event.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; + +export const STORAGE_KEY_LAST_SESSION_TYPE = 'sessions.lastSelectedSessionType'; export class SessionTypePicker extends Disposable { @@ -33,9 +36,13 @@ export class SessionTypePicker extends Disposable { @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, + @IStorageService protected readonly storageService: IStorageService, ) { super(); + // Restore the previously selected session type from storage + this._sessionType = this.storageService.get(STORAGE_KEY_LAST_SESSION_TYPE, StorageScope.PROFILE); + const refresh = (session: ISession | undefined) => { if (session) { const provider = this.sessionsProvidersService.getProvider(session.providerId); @@ -50,7 +57,9 @@ export class SessionTypePicker extends Disposable { } else { this._supportedSessionTypes = []; this._allProviderSessionTypes = []; - this._sessionType = undefined; + // Preserve the stored session type when no active session exists, + // so it can be used as the default for the next new session. + this._sessionType = this.storageService.get(STORAGE_KEY_LAST_SESSION_TYPE, StorageScope.PROFILE); } this._updateTriggerLabel(); }; @@ -132,6 +141,7 @@ export class SessionTypePicker extends Disposable { onSelect: (type) => { this.actionWidgetService.hide(); if (type.id !== this._sessionType) { + this.storageService.store(STORAGE_KEY_LAST_SESSION_TYPE, type.id, StorageScope.PROFILE, StorageTarget.MACHINE); this._onDidSelectSessionType.fire(type.id); } }, diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index e6d9848aac7fda..0f6528c9ceffd8 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -344,7 +344,7 @@ export class WorkspacePicker extends Disposable { private _buildDelegate(triggerElement: HTMLElement, hide: () => void): IActionListDelegate { return { onSelect: (item) => { - this.actionWidgetService.hide(); + hide(); if (item.commandId) { this.commandService.executeCommand(item.commandId); } else if (item.selection && this._isProviderUnavailable(item.selection.providerId)) { diff --git a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts index 913de098dc0826..a4227c8aa39ed8 100644 --- a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts @@ -3,7 +3,67 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { ipcRenderer } from '../../../../base/parts/sandbox/electron-browser/globals.js'; +import { URI, UriComponents } from '../../../../base/common/uri.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; import { registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { ILifecycleService, LifecyclePhase } from '../../../../workbench/services/lifecycle/common/lifecycle.js'; +import { NewChatViewPane, SessionsViewId } from '../browser/newChatViewPane.js'; import { DebugAgentHostInDevToolsAction } from '../../../../workbench/contrib/chat/electron-browser/actions/debugAgentHostAction.js'; +import { CollectAgentHostDebugLogsAction } from '../../agentHost/electron-browser/collectDebugLogsAction.js'; registerAction2(DebugAgentHostInDevToolsAction); +registerAction2(CollectAgentHostDebugLogsAction); + +class SelectAgentsFolderContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.selectAgentsFolder'; + + constructor( + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, + @IViewsService private readonly viewsService: IViewsService, + @ILifecycleService private readonly lifecycleService: ILifecycleService, + ) { + super(); + ipcRenderer.on('vscode:selectAgentsFolder', (_: unknown, ...args: unknown[]) => { + const folderUri = URI.revive(args[0] as UriComponents); + this.selectFolder(folderUri); + }); + } + + private async selectFolder(folderUri: URI): Promise { + this.sessionsManagementService.openNewSessionView(); + + if (this.tryResolveAndSelect(folderUri)) { + return; + } + + // Provider not registered yet — wait for it, but give up at Eventually phase + const disposable = this.sessionsProvidersService.onDidChangeProviders(() => { + if (this.tryResolveAndSelect(folderUri)) { + disposable.dispose(); + } + }); + this.lifecycleService.when(LifecyclePhase.Eventually).then(() => disposable.dispose()); + } + + private tryResolveAndSelect(folderUri: URI): boolean { + for (const provider of this.sessionsProvidersService.getProviders()) { + const workspace = provider.resolveWorkspace(folderUri); + if (workspace) { + this.viewsService.openView(SessionsViewId).then(view => { + view?.selectWorkspace({ providerId: provider.id, workspace }); + }); + return true; + } + } + return false; + } +} + +registerWorkbenchContribution2(SelectAgentsFolderContribution.ID, SelectAgentsFolderContribution, WorkbenchPhase.BlockStartup); diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts index fc1821a80155d4..df941b6c04b567 100644 --- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -5,82 +5,11 @@ import { Extensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; -import { ThemeSettingDefaults } from '../../../../workbench/services/themes/common/workbenchThemeService.js'; Registry.as(Extensions.Configuration).registerDefaultConfigurations([{ overrides: { - 'breadcrumbs.enabled': false, - - 'chat.experimentalSessionsWindowOverride': true, - 'chat.hookFilesLocations': { - '.claude/settings.local.json': false, - '.claude/settings.json': false, - '~/.claude/settings.json': false, - }, - 'chat.agent.maxRequests': 1000, 'chat.customizationsMenu.userStoragePath': '~/.copilot', - 'chat.viewSessions.enabled': false, - 'chat.implicitContext.suggestedContext': false, - 'chat.implicitContext.enabled': { 'panel': 'never' }, - 'chat.tools.terminal.enableAutoApprove': true, - - 'diffEditor.hideUnchangedRegions.enabled': true, - 'diffEditor.renderGutterMenu': false, - 'diffEditor.renderIndicators': false, - 'diffEditor.renderMarginRevertIcon': false, - 'diffEditor.renderSideBySide': true, - 'diffEditor.useInlineViewWhenSpaceIsLimited': true, - - 'extensions.ignoreRecommendations': true, - - 'files.autoSave': 'afterDelay', - - 'git.autofetch': true, - 'git.autorefresh': true, - 'git.branchRandomName.enable': true, - 'git.detectWorktrees': false, - 'git.showProgress': false, - - 'github.copilot.enable': { - 'markdown': true, - 'plaintext': true, - }, 'github.copilot.chat.claudeCode.enabled': true, - 'github.copilot.chat.cli.autoCommit.enabled': false, - 'github.copilot.chat.cli.branchSupport.enabled': true, - 'github.copilot.chat.cli.isolationOption.enabled': true, - 'github.copilot.chat.cli.sessionController.enabled': false, - 'github.copilot.chat.cli.lazyLoadSessionItem.enabled': false, - 'github.copilot.chat.cli.mcp.enabled': true, - 'github.copilot.chat.cli.remote.enabled': false, - 'github.copilot.chat.githubMcpServer.enabled': true, - 'github.copilot.chat.languageContext.typescript.enabled': true, - 'github.copilot.chat.cli.showExternalSessions': false, - - 'inlineChat.affordance': 'editor', - - 'search.quickOpen.includeHistory': false, - - 'task.notifyWindowOnTaskCompletion': -1, - - 'terminal.integrated.initialHint': false, - - 'workbench.browser.openLocalhostLinks': true, - 'workbench.browser.enableChatTools': true, - - 'workbench.editor.doubleClickTabToToggleEditorGroupSizes': 'maximize', - 'workbench.editor.restoreEditors': false, - 'update.showReleaseNotes': false, - 'workbench.notifications.position': 'bottom-right', - 'workbench.startupEditor': 'none', - 'workbench.tips.enabled': false, - 'workbench.layoutControl.type': 'toggles', - 'workbench.editor.useModal': 'all', - 'workbench.panel.showLabels': false, - 'workbench.colorTheme': ThemeSettingDefaults.COLOR_THEME_DARK, - - 'window.menuStyle': 'custom', - 'window.dialogStyle': 'custom', }, donotCache: true, preventExperimentOverride: true, diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 3cb428c22977ae..ee94a2676fba40 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -42,10 +42,12 @@ import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IGitHubService } from '../../github/browser/githubService.js'; import { computePullRequestIcon, GitHubPullRequestState } from '../../github/common/types.js'; const SESSION_WORKSPACE_GROUP_GITHUB = localize('sessionWorkspaceGroup.github', "GitHub"); +const STORAGE_KEY_ISOLATION_MODE = 'sessions.isolationPicker.selectedMode'; export interface ICopilotChatSession { /** Globally unique session ID (`providerId:localId`). */ @@ -238,6 +240,7 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { providerId: string, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IGitService private readonly gitService: IGitService, + @IStorageService private readonly storageService: IStorageService, ) { super(); this.id = toSessionId(providerId, resource); @@ -255,8 +258,11 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { // Set ISessionData workspace observable this._workspaceData.set(sessionWorkspace, undefined); - this._isolationMode = 'worktree'; - this.setOption(ISOLATION_OPTION_ID, 'worktree'); + const storedMode = storageService.get(STORAGE_KEY_ISOLATION_MODE, StorageScope.PROFILE); + const initialMode: IsolationMode = storedMode === 'workspace' ? 'workspace' : 'worktree'; + this._isolationMode = initialMode; + this._isolationModeObservable.set(initialMode, undefined); + this.setOption(ISOLATION_OPTION_ID, initialMode); // Resolve git repository asynchronously this._resolveGitRepository(); @@ -354,6 +360,7 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { this._isolationMode = mode; this._isolationModeObservable.set(mode, undefined); this.setOption(ISOLATION_OPTION_ID, mode); + this.storageService.store(STORAGE_KEY_ISOLATION_MODE, mode, StorageScope.PROFILE, StorageTarget.MACHINE); if (mode === 'workspace') { // When switching to workspace mode, update the branch diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index 8981bda2005bae..13b4a28d8d65de 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -28,6 +28,8 @@ import product from '../../../../platform/product/common/product.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { OpenSessionEventsFileAction } from '../../agentHost/browser/openSessionEventsFileActions.js'; import { AgentCustomizationSyncProvider } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationSyncProvider.js'; import { authenticateProtectedResources, AgentHostAuthTokenCache, resolveAuthenticationInteractively } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.js'; import { AgentHostLanguageModelProvider } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.js'; @@ -588,6 +590,8 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc registerWorkbenchContribution2(RemoteAgentHostContribution.ID, RemoteAgentHostContribution, WorkbenchPhase.AfterRestored); +registerAction2(OpenSessionEventsFileAction); + Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ properties: { [RemoteAgentHostsEnabledSettingId]: { diff --git a/src/vs/sessions/contrib/search/browser/search.contribution.ts b/src/vs/sessions/contrib/search/browser/search.contribution.ts new file mode 100644 index 00000000000000..7e1a16d0dd6ffd --- /dev/null +++ b/src/vs/sessions/contrib/search/browser/search.contribution.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; +import { KeybindingWeight, KeybindingsRegistry } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { OpenEditorCommandId } from '../../../../workbench/contrib/searchEditor/browser/constants.js'; + +KeybindingsRegistry.registerKeybindingRule({ + id: OpenEditorCommandId, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyF, + weight: KeybindingWeight.WorkbenchContrib, +}); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index 82d291762478eb..073f12a21dff23 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -5,9 +5,10 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; +import { isMobile, isWeb } from '../../../../../base/common/platform.js'; import { localize, localize2 } from '../../../../../nls.js'; import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; -import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; +import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -15,6 +16,7 @@ import { IQuickInputService } from '../../../../../platform/quickinput/common/qu import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; +import { CLOSE_MOBILE_SIDEBAR_DRAWER_COMMAND_ID } from '../../../../browser/workbench.js'; import { EditorsVisibleContext, IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../../../../workbench/common/contextkeys.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { AUX_WINDOW_GROUP } from '../../../../../workbench/services/editor/common/editorService.js'; @@ -347,12 +349,19 @@ registerAction2(class NewSessionForWorkspaceAction extends Action2 { } const sessionsManagementService = accessor.get(ISessionsManagementService); const viewsService = accessor.get(IViewsService); + const commandService = accessor.get(ICommandService); sessionsManagementService.openNewSessionView(); const view = await viewsService.openView(NewChatViewId, true); const workspace = context.sessions[0].workspace.get(); if (view && workspace) { view.selectWorkspace({ providerId: context.sessions[0].providerId, workspace }); } + // On mobile web, the sidebar drawer covers the viewport; close it so + // the new session view becomes visible after creation. Routes through + // the drawer-close command to keep the mobile nav/history stack in sync. + if (isWeb && isMobile) { + commandService.executeCommand(CLOSE_MOBILE_SIDEBAR_DRAWER_COMMAND_ID); + } } }); diff --git a/src/vs/sessions/services/configuration/browser/configurationService.ts b/src/vs/sessions/services/configuration/browser/configurationService.ts index 01fb00152a6a1f..4aaacb896a325d 100644 --- a/src/vs/sessions/services/configuration/browser/configurationService.ts +++ b/src/vs/sessions/services/configuration/browser/configurationService.ts @@ -13,13 +13,13 @@ import { VSBuffer } from '../../../../base/common/buffer.js'; import { JSONPath, ParseError, parse } from '../../../../base/common/json.js'; import { applyEdits, setProperty } from '../../../../base/common/jsonEdit.js'; import { Edit, FormattingOptions } from '../../../../base/common/jsonFormatter.js'; -import { equals } from '../../../../base/common/objects.js'; +import { deepClone, equals } from '../../../../base/common/objects.js'; import { distinct, equals as arrayEquals } from '../../../../base/common/arrays.js'; import { OS, OperatingSystem } from '../../../../base/common/platform.js'; import { IConfigurationChange, IConfigurationChangeEvent, IConfigurationData, IConfigurationOverrides, IConfigurationUpdateOptions, IConfigurationUpdateOverrides, IConfigurationValue, ConfigurationTarget, isConfigurationOverrides, isConfigurationUpdateOverrides } from '../../../../platform/configuration/common/configuration.js'; import { ConfigurationChangeEvent, ConfigurationModel } from '../../../../platform/configuration/common/configurationModels.js'; import { DefaultConfiguration, IPolicyConfiguration, NullPolicyConfiguration, PolicyConfiguration } from '../../../../platform/configuration/common/configurations.js'; -import { Extensions, IConfigurationRegistry, keyFromOverrideIdentifiers } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { Extensions, IConfigurationRegistry, IRegisteredConfigurationPropertySchema, keyFromOverrideIdentifiers } from '../../../../platform/configuration/common/configurationRegistry.js'; import { IFileService, FileOperationError, FileOperationResult } from '../../../../platform/files/common/files.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IPolicyService, NullPolicyService } from '../../../../platform/policy/common/policy.js'; @@ -34,6 +34,17 @@ import { IUserDataProfileService } from '../../../../workbench/services/userData // Import to register configuration contributions import '../../../../workbench/services/configuration/browser/configurationService.js'; +class SessionsDefaultConfiguration extends DefaultConfiguration { + + protected override getDefaultValue(_key: string, propertySchema: IRegisteredConfigurationPropertySchema): unknown { + if (propertySchema.agentsWindow) { + return deepClone(propertySchema.agentsWindow.default); + } + return super.getDefaultValue(_key, propertySchema); + } + +} + export class ConfigurationService extends Disposable implements IWorkbenchConfigurationService { declare readonly _serviceBrand: undefined; @@ -43,6 +54,7 @@ export class ConfigurationService extends Disposable implements IWorkbenchConfig private readonly policyConfiguration: IPolicyConfiguration; private readonly userConfiguration: UserConfiguration; private readonly cachedFolderConfigs = this._register(new DisposableMap(new ResourceMap())); + private readonly agentsWindowReadOnlyKeys = new Set(); private readonly _onDidChangeConfiguration = this._register(new Emitter()); readonly onDidChangeConfiguration = this._onDidChangeConfiguration.event; @@ -66,9 +78,10 @@ export class ConfigurationService extends Disposable implements IWorkbenchConfig super(); this.settingsResource = userDataProfileService.currentProfile.settingsResource; - this.defaultConfiguration = this._register(new DefaultConfiguration(logService)); + this.defaultConfiguration = this._register(new SessionsDefaultConfiguration(logService)); this.policyConfiguration = policyService instanceof NullPolicyService ? new NullPolicyConfiguration() : this._register(new PolicyConfiguration(this.defaultConfiguration, policyService, logService)); - this.userConfiguration = this._register(new UserConfiguration(userDataProfileService.currentProfile.settingsResource, userDataProfileService.currentProfile.tasksResource, userDataProfileService.currentProfile.mcpResource, {}, fileService, uriIdentityService, logService)); + this.initAgentsWindowReadOnlyKeys(); + this.userConfiguration = this._register(new UserConfiguration(userDataProfileService.currentProfile.settingsResource, userDataProfileService.currentProfile.tasksResource, userDataProfileService.currentProfile.mcpResource, { exclude: [...this.agentsWindowReadOnlyKeys] }, fileService, uriIdentityService, logService)); this.configurationEditing = new ConfigurationEditing(fileService, this); this._configuration = new Configuration( @@ -150,6 +163,10 @@ export class ConfigurationService extends Disposable implements IWorkbenchConfig throw new Error(`Unable to write ${key} because it is configured in system policy.`); } + if (this.agentsWindowReadOnlyKeys.has(key)) { + throw new Error(`Unable to write ${key} because it is read-only in the Agents window.`); + } + // Remove the setting, if the value is same as default value if (equals(value, inspect.defaultValue)) { value = undefined; @@ -226,12 +243,35 @@ export class ConfigurationService extends Disposable implements IWorkbenchConfig // #endregion + private initAgentsWindowReadOnlyKeys(): void { + const properties = this.configurationRegistry.getConfigurationProperties(); + for (const key in properties) { + if (properties[key].agentsWindow?.readOnly) { + this.agentsWindowReadOnlyKeys.add(key); + } + } + } + + private updateAgentsWindowReadOnlyKeys(changedProperties: string[]): void { + const properties = this.configurationRegistry.getConfigurationProperties(); + for (const key of changedProperties) { + if (properties[key]?.agentsWindow?.readOnly) { + this.agentsWindowReadOnlyKeys.add(key); + } else { + this.agentsWindowReadOnlyKeys.delete(key); + } + } + } + // #region Configuration change handlers private onDefaultConfigurationChanged(defaults: ConfigurationModel, properties?: string[]): void { + if (properties) { + this.updateAgentsWindowReadOnlyKeys(properties); + } const previousData = this._configuration.toData(); const change = this._configuration.compareAndUpdateDefaultConfiguration(defaults, properties); - this._configuration.updateLocalUserConfiguration(this.userConfiguration.reparse()); + this._configuration.updateLocalUserConfiguration(this.userConfiguration.reparse({ exclude: [...this.agentsWindowReadOnlyKeys] })); for (const folder of this.workspaceService.getWorkspace().folders) { const folderConfiguration = this.cachedFolderConfigs.get(folder.uri); if (folderConfiguration) { diff --git a/src/vs/sessions/services/configuration/test/browser/configurationService.test.ts b/src/vs/sessions/services/configuration/test/browser/configurationService.test.ts index fdfd8a4f1e9f44..cec9d15503fe09 100644 --- a/src/vs/sessions/services/configuration/test/browser/configurationService.test.ts +++ b/src/vs/sessions/services/configuration/test/browser/configurationService.test.ts @@ -59,6 +59,24 @@ suite('Sessions ConfigurationService', () => { 'default': 'defaultValue', scope: ConfigurationScope.APPLICATION }, + 'sessionsConfigurationService.agentsWindowDefault': { + 'type': 'string', + 'default': 'originalDefault', + scope: ConfigurationScope.RESOURCE, + agentsWindow: { default: 'agentsDefault' } + }, + 'sessionsConfigurationService.agentsWindowReadOnly': { + 'type': 'string', + 'default': 'originalDefault', + scope: ConfigurationScope.RESOURCE, + agentsWindow: { default: 'readOnlyDefault', readOnly: true } + }, + 'sessionsConfigurationService.agentsWindowDefaultOnly': { + 'type': 'boolean', + 'default': false, + scope: ConfigurationScope.RESOURCE, + agentsWindow: { default: true } + }, } }); }); @@ -336,4 +354,64 @@ suite('Sessions ConfigurationService', () => { })); // #endregion + + // #region Agents Window Configuration + + test('agentsWindow.default overrides the default value', () => { + assert.strictEqual(testObject.getValue('sessionsConfigurationService.agentsWindowDefault'), 'agentsDefault'); + }); + + test('agentsWindow.default is reflected in inspect', () => { + const inspection = testObject.inspect('sessionsConfigurationService.agentsWindowDefault'); + assert.strictEqual(inspection.defaultValue, 'agentsDefault'); + }); + + test('agentsWindow.default with boolean value', () => { + assert.strictEqual(testObject.getValue('sessionsConfigurationService.agentsWindowDefaultOnly'), true); + }); + + test('agentsWindow.readOnly setting uses overridden default', () => { + assert.strictEqual(testObject.getValue('sessionsConfigurationService.agentsWindowReadOnly'), 'readOnlyDefault'); + }); + + test('agentsWindow.readOnly setting rejects writes', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await assert.rejects( + () => testObject.updateValue('sessionsConfigurationService.agentsWindowReadOnly', 'newValue'), + /read-only in the Agents window/ + ); + })); + + test('agentsWindow.readOnly setting ignores user settings file values', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await fileService.writeFile(userDataProfileService.currentProfile.settingsResource, VSBuffer.fromString('{ "sessionsConfigurationService.agentsWindowReadOnly": "userValue" }')); + await testObject.reloadConfiguration(); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.agentsWindowReadOnly'), 'readOnlyDefault'); + })); + + test('user settings override agentsWindow.default when not readOnly', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await fileService.writeFile(userDataProfileService.currentProfile.settingsResource, VSBuffer.fromString('{ "sessionsConfigurationService.agentsWindowDefault": "userValue" }')); + await testObject.reloadConfiguration(); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.agentsWindowDefault'), 'userValue'); + })); + + test('agentsWindow.readOnly setting added dynamically is picked up', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + configurationRegistry.registerConfiguration({ + 'id': '_test_sessions_dynamic', + 'type': 'object', + 'properties': { + 'sessionsConfigurationService.dynamicReadOnly': { + 'type': 'string', + 'default': 'originalDefault', + scope: ConfigurationScope.RESOURCE, + agentsWindow: { default: 'dynamicDefault', readOnly: true } + }, + } + }); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.dynamicReadOnly'), 'dynamicDefault'); + await assert.rejects( + () => testObject.updateValue('sessionsConfigurationService.dynamicReadOnly', 'newValue'), + /read-only in the Agents window/ + ); + })); + + // #endregion }); diff --git a/src/vs/sessions/services/vscode/browser/themeImporterService.ts b/src/vs/sessions/services/vscode/browser/themeImporterService.ts new file mode 100644 index 00000000000000..63bf1f07b6e149 --- /dev/null +++ b/src/vs/sessions/services/vscode/browser/themeImporterService.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { IThemeImporterService, IThemePreviewResult } from '../common/themeImporter.js'; + +/** + * Browser/web no-op implementation of {@link IThemeImporterService}. The web + * variant of the Agents app does not have access to a parent VS Code + * installation, so theme importing is unavailable. + */ +class BrowserThemeImporterService implements IThemeImporterService { + + declare readonly _serviceBrand: undefined; + + async getVSCodeTheme(): Promise { + return undefined; + } + + async previewVSCodeTheme(): Promise { + return undefined; + } +} + +registerSingleton(IThemeImporterService, BrowserThemeImporterService, InstantiationType.Delayed); diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index 5beb67a6878229..b0b1f216623fca 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -251,6 +251,11 @@ import '../workbench/contrib/inlineCompletions/browser/renameSymbolTrackerServic // Search Quick Access (file picker only, not the full search contribution) import '../workbench/contrib/search/browser/searchQuickAccess.contribution.js'; +// Search Editor +import '../workbench/contrib/searchEditor/browser/searchEditor.contribution.js'; +import '../workbench/contrib/search/browser/search.common.contribution.js'; +import './contrib/search/browser/search.contribution.js'; + // Sash import '../workbench/contrib/sash/browser/sash.contribution.js'; @@ -304,6 +309,9 @@ registerSingleton(IExtensionsWorkbenchService, ExtensionsWorkbenchService, Insta import '../workbench/contrib/output/browser/output.contribution.js'; import '../workbench/contrib/output/browser/outputView.js'; +// Problems View +import '../workbench/contrib/markers/browser/markers.contribution.js'; + // Terminal import '../workbench/contrib/terminal/terminal.all.js'; diff --git a/src/vs/sessions/sessions.web.main.ts b/src/vs/sessions/sessions.web.main.ts index 8054d65f059ff0..b505bd39c07932 100644 --- a/src/vs/sessions/sessions.web.main.ts +++ b/src/vs/sessions/sessions.web.main.ts @@ -71,6 +71,7 @@ import '../platform/extensionResourceLoader/browser/extensionResourceLoaderServi import '../workbench/services/auxiliaryWindow/browser/auxiliaryWindowService.js'; import '../workbench/services/power/browser/powerService.js'; import '../platform/sandbox/browser/sandboxHelperService.js'; +import './services/vscode/browser/themeImporterService.js'; import { InstantiationType, registerSingleton } from '../platform/instantiation/common/extensions.js'; import { IAccessibilityService } from '../platform/accessibility/common/accessibility.js'; diff --git a/src/vs/workbench/api/common/configurationExtensionPoint.ts b/src/vs/workbench/api/common/configurationExtensionPoint.ts index 46304274ef0bf4..6c1492b69ad507 100644 --- a/src/vs/workbench/api/common/configurationExtensionPoint.ts +++ b/src/vs/workbench/api/common/configurationExtensionPoint.ts @@ -19,6 +19,7 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { SyncDescriptor } from '../../../platform/instantiation/common/descriptors.js'; import { MarkdownString } from '../../../base/common/htmlContent.js'; import product from '../../../platform/product/common/product.js'; +import { isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; const jsonRegistry = Registry.as(JSONExtensions.JSONContribution); const configurationRegistry = Registry.as(Extensions.Configuration); @@ -145,6 +146,21 @@ const configurationEntrySchema: IJSONSchema = { }, additionalItems: true, markdownDescription: nls.localize('scope.tags', 'A list of tags under which to place the setting. The tag can then be searched up in the Settings editor. For example, specifying the `experimental` tag allows one to find the setting by searching `@tag:experimental`.'), + }, + agentsWindow: { + type: 'object', + markdownDescription: nls.localize('scope.agentsWindow', "Configuration overrides for the Agents window. Allows specifying a different default value and read-only behavior for this setting when running in the Agents window.\n\n**Note**: This is a proposed API. To use it, extensions must include `agentsWindowConfiguration` in their `enabledApiProposals`."), + properties: { + 'default': { + description: nls.localize('scope.agentsWindow.default', 'The default value for this setting in the Agents window.'), + }, + readOnly: { + type: 'boolean', + description: nls.localize('scope.agentsWindow.readOnly', 'When true, this setting cannot be changed by the user in the Agents window.'), + default: false, + } + }, + additionalProperties: false } } } @@ -299,6 +315,10 @@ configurationExtPoint.setHandler((extensions, { added, removed }) => { mode: 'startup' }; } + if (propertyConfiguration.agentsWindow && !isProposedApiEnabled(extension.description, 'agentsWindowConfiguration')) { + extension.collector.error(nls.localize('config.property.agentsWindow.proposed', "Extension '{0}' CANNOT use 'agentsWindow' property on configuration '{1}' without enabling the 'agentsWindowConfiguration' API proposal.", extension.description.identifier.value, key)); + delete propertyConfiguration.agentsWindow; + } seenProperties.add(key); propertyConfiguration.scope = propertyConfiguration.scope ? parseScope(propertyConfiguration.scope.toString()) : ConfigurationScope.WINDOW; } diff --git a/src/vs/workbench/browser/parts/editor/breadcrumbs.ts b/src/vs/workbench/browser/parts/editor/breadcrumbs.ts index 2daec62c34b11c..348ec3327859e1 100644 --- a/src/vs/workbench/browser/parts/editor/breadcrumbs.ts +++ b/src/vs/workbench/browser/parts/editor/breadcrumbs.ts @@ -125,7 +125,8 @@ Registry.as(Extensions.Configuration).registerConfigurat 'breadcrumbs.enabled': { description: localize('enabled', "Enable/disable navigation breadcrumbs."), type: 'boolean', - default: true + default: true, + agentsWindow: { default: false }, }, 'breadcrumbs.filePath': { description: localize('filepath', "Controls whether and how file paths are shown in the breadcrumbs view."), diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index b164dc915f1371..95accefa0a8d1c 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -362,7 +362,8 @@ const registry = Registry.as(ConfigurationExtensions.Con localize('useModal.all', "All editors open in a centered modal overlay."), ], 'description': localize('useModal', "Controls whether editors open in a modal overlay."), - 'default': 'some' + 'default': 'some', + agentsWindow: { default: 'all' }, }, 'workbench.editor.swipeToNavigate': { 'type': 'boolean', @@ -400,7 +401,8 @@ const registry = Registry.as(ConfigurationExtensions.Con 'workbench.editor.restoreEditors': { 'type': 'boolean', 'description': localize('restoreOnStartup', "Controls whether editors are restored on startup. When disabled, only dirty editors will be restored from the previous session."), - 'default': true + 'default': true, + agentsWindow: { default: false, readOnly: true }, }, 'workbench.editor.splitInGroupLayout': { 'type': 'string', @@ -431,7 +433,8 @@ const registry = Registry.as(ConfigurationExtensions.Con localize('workbench.editor.doubleClickTabToToggleEditorGroupSizes.maximize', "All other editor groups are hidden and the current editor group is maximized to take up the entire editor area."), localize('workbench.editor.doubleClickTabToToggleEditorGroupSizes.expand', "The editor group takes as much space as possible by making all other editor groups as small as possible."), localize('workbench.editor.doubleClickTabToToggleEditorGroupSizes.off', "No editor group is resized when double clicking on a tab.") - ] + ], + agentsWindow: { default: 'maximize' }, }, 'workbench.editor.limit.enabled': { 'type': 'boolean', @@ -569,6 +572,7 @@ const registry = Registry.as(ConfigurationExtensions.Con 'type': 'boolean', 'default': true, 'description': localize('panelShowLabels', "Controls whether activity items in the panel title are shown as label or icon."), + agentsWindow: { default: false }, }, 'workbench.panel.defaultLocation': { 'type': 'string', @@ -777,11 +781,13 @@ const registry = Registry.as(ConfigurationExtensions.Con ], 'default': 'both', 'description': localize('layoutControlType', "Controls whether the layout control in the custom title bar is displayed as a single menu button or with multiple UI toggles."), + agentsWindow: { default: 'toggles' }, }, 'workbench.tips.enabled': { 'type': 'boolean', 'default': true, - 'description': localize('tips.enabled', "When enabled, will show the watermark tips when no editor is open.") + 'description': localize('tips.enabled', "When enabled, will show the watermark tips when no editor is open."), + agentsWindow: { default: false }, }, [LayoutSettings.SHADOWS]: { 'type': 'boolean', 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 3ac43114c5e389..b3a2d9e90fda30 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts @@ -562,7 +562,8 @@ Registry.as(ConfigurationExtensions.Configuration).regis value: localize('browser.enableChatTools', 'When enabled, chat agents can use browser tools to open and interact with pages in the Integrated Browser.') } }, - } + }, + agentsWindow: { default: true }, }, [AgentHostChatToolsEnabledSettingId]: { type: 'boolean', diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts index b7ef9c2df20737..f3ed5a35eb71c5 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts @@ -697,7 +697,8 @@ Registry.as(ConfigurationExtensions.Configuration).regis markdownDescription: localize( { comment: ['This is the description for a setting.'], key: 'browser.openLocalhostLinks' }, 'When enabled, localhost links (`localhost`, `127.0.0.1`, `[::1]`) and all-interfaces links (`0.0.0.0`, `[0:0:0:0:0:0:0:0]`, `[::]`) from the terminal, chat, and other sources will open in the Integrated Browser instead of the system browser.' - ) + ), + agentsWindow: { default: true }, } } }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts index 0d35d1bba75b7f..5e88b5b0ee5e56 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts @@ -11,7 +11,7 @@ import { IAgentConnection, IAgentCreateSessionConfig, IAgentResolveSessionConfig import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { StateComponents, type ComponentToState, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import type { ActionEnvelope, IRootConfigChangedAction, SessionAction, TerminalAction, INotification } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; -import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; +import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { Extensions, IOutputChannel, IOutputChannelRegistry, IOutputService } from '../../../../../services/output/common/output.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -192,6 +192,10 @@ export class LoggingAgentConnection extends Disposable implements IAgentConnecti return this._logCall('sessionConfigCompletions', params, () => this._inner.sessionConfigCompletions(params)); } + async completions(params: CompletionsParams): Promise { + return this._logCall('completions', params, () => this._inner.completions(params)); + } + async disposeSession(session: URI): Promise { return this._logCall('disposeSession', session, () => this._inner.disposeSession(session)); } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index af383538981ee6..10afb3603743c3 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -216,6 +216,7 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.experimentalSessionsWindowOverride', "When true, enables sessions-window-specific behavior for extensions."), default: false, tags: ['experimental'], + agentsWindow: { default: true }, }, 'chat.fontSize': { type: 'number', @@ -298,12 +299,14 @@ configurationRegistry.registerConfiguration({ tags: ['experimental'], experiment: { mode: 'startup' - } + }, + agentsWindow: { default: { 'panel': 'never' } }, }, 'chat.implicitContext.suggestedContext': { type: 'boolean', markdownDescription: nls.localize('chat.implicitContext.suggestedContext', "Controls whether the new implicit context flow is shown. In Ask and Edit modes, the context will automatically be included. When using an agent, context will be suggested as an attachment. Selections are always included as context."), default: true, + agentsWindow: { default: false }, }, 'chat.editing.autoAcceptDelay': { type: 'number', @@ -655,6 +658,7 @@ configurationRegistry.registerConfiguration({ type: 'boolean', default: true, description: nls.localize('chat.viewSessions.enabled', "Show chat agent sessions when chat is empty or to the side when chat view is wide enough."), + agentsWindow: { default: false }, }, [ChatConfiguration.ChatViewSessionsOrientation]: { type: 'string', @@ -1388,6 +1392,7 @@ configurationRegistry.registerConfiguration({ 'custom-hooks/hooks.json': true, }, ], + agentsWindow: { default: { '.claude/settings.local.json': false, '.claude/settings.json': false, '~/.claude/settings.json': false } }, }, [PromptsConfig.USE_CHAT_HOOKS]: { type: 'boolean', @@ -1927,6 +1932,7 @@ class ChatAgentSettingContribution extends Disposable implements IWorkbenchContr markdownDescription: nls.localize('chat.agent.maxRequests', "The maximum number of requests to allow per-turn when using an agent. When the limit is reached, will ask to confirm to continue."), default: value ?? 50, order: 2, + agentsWindow: { default: 1000 }, }, } }; diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts index cf329b280dc118..3e1185bd00e8d4 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts @@ -24,6 +24,7 @@ import product from '../../../../../platform/product/common/product.js'; import { isCompletionsEnabled } from '../../../../../editor/common/services/completionsEnablement.js'; import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { CHAT_SETUP_ACTION_ID } from '../actions/chatActions.js'; export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribution { @@ -149,10 +150,7 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu isProUser(entitlement) || // user is already pro entitlement === ChatEntitlement.Free // user is already free ) { - const signIn = localize('signInSetup', "Sign In"); - - text = `$(copilot) ${signIn}`; - ariaLabel = signIn; + return this.getSetupEntryProps(); } } else { const chatQuotaExceeded = this.chatEntitlementService.quotas.chat?.percentRemaining === 0; @@ -176,9 +174,7 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu // Signed out else if (this.chatEntitlementService.entitlement === ChatEntitlement.Unknown) { - const signIn = localize('signIn', "Sign In"); - text = `$(copilot) ${signIn}`; - ariaLabel = signIn; + return this.getSetupEntryProps(); } // Free Quota Exceeded @@ -241,6 +237,18 @@ export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribu return baseResult; } + private getSetupEntryProps(): IStatusbarEntry { + const signInLabel = localize('signIn', "Sign In"); + return { + name: localize('chatStatus', "Copilot Status"), + text: `$(copilot) ${signInLabel}`, + ariaLabel: signInLabel, + command: CHAT_SETUP_ACTION_ID, + showInAllWindows: true, + kind: undefined, + }; + } + override dispose(): void { super.dispose(); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 3aad8c6c0eb60d..0a594cc168dc03 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -1178,8 +1178,16 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge .map(modelId => ({ identifier: modelId, metadata: this.languageModelsService.lookupLanguageModel(modelId)! })); const contributedVendors = new Set(this.languageModelsService.getVendors().map(v => v.vendor)); - const models = mergeModelsWithCache(liveModels, cachedModels, contributedVendors); - if (liveModels.length > 0) { + const resolvedVendors = new Set(); + for (const v of contributedVendors) { + if (this.languageModelsService.hasResolvedVendor(v)) { + resolvedVendors.add(v); + } + } + const models = mergeModelsWithCache(liveModels, cachedModels, contributedVendors, resolvedVendors); + // Persist whenever we have any authoritative information — either live + // models, or at least one resolved vendor (so cache eviction sticks). + if (liveModels.length > 0 || resolvedVendors.size > 0) { this.storageService.store(CachedLanguageModelsKey, models, StorageScope.APPLICATION, StorageTarget.MACHINE); } return models; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelSelectionLogic.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelSelectionLogic.ts index d28039238ab697..75c73afe84444c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelSelectionLogic.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelSelectionLogic.ts @@ -222,24 +222,32 @@ export function resolveModelFromSyncState( } /** - * Merges live models with cached models per-vendor. - * For vendors whose models have resolved, uses live data. - * For vendors that are contributed but haven't resolved yet (startup race), keeps cached models. - * Vendors no longer contributed are evicted from cache. + * Merges live models with cached models per-vendor, evicting cache for vendors + * no longer contributed. + * + * - `resolvedVendors`: vendors whose providers have produced at least one + * result. An empty live list for these is authoritative (e.g. BYOK key + * removed) and their cache entries are dropped. + * - When no contributor info is available yet and there are no live models + * (startup / extension reload), the full cache is returned to avoid + * flickering the picker to empty. */ export function mergeModelsWithCache( liveModels: ILanguageModelChatMetadataAndIdentifier[], cachedModels: ILanguageModelChatMetadataAndIdentifier[], contributedVendors: Set, + resolvedVendors?: ReadonlySet, ): ILanguageModelChatMetadataAndIdentifier[] { - if (liveModels.length > 0) { - const liveVendors = new Set(liveModels.map(m => m.metadata.vendor)); - return [ - ...liveModels, - ...cachedModels.filter(m => !liveVendors.has(m.metadata.vendor) && contributedVendors.has(m.metadata.vendor)), - ]; + if (contributedVendors.size === 0 && liveModels.length === 0) { + return cachedModels; } - return cachedModels; + const liveVendors = new Set(liveModels.map(m => m.metadata.vendor)); + const usableCached = cachedModels.filter(m => + contributedVendors.has(m.metadata.vendor) && + !liveVendors.has(m.metadata.vendor) && + !resolvedVendors?.has(m.metadata.vendor) + ); + return [...liveModels, ...usableCached]; } /** diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index b2c04973cd21b0..1284cd8a2c2082 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -33,7 +33,7 @@ import { IChatDebugService } from '../chatDebugService.js'; import { IMcpService } from '../../../mcp/common/mcpTypes.js'; import { awaitStatsForSession } from '../chat.js'; import { ChatPerfMark, clearChatMarks, markChat } from '../chatPerf.js'; -import { IChatAgentCommand, IChatAgentData, IChatAgentHistoryEntry, IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../participants/chatAgents.js'; +import { IChatAgentAttachmentCapabilities, IChatAgentCommand, IChatAgentData, IChatAgentHistoryEntry, IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../participants/chatAgents.js'; import { chatEditingSessionIsReady } from '../editing/chatEditingService.js'; import { ChatModel, ChatRequestModel, ChatRequestRemovalReason, IChatModel, IChatRequestModel, IChatRequestModeInfo, IChatRequestVariableData, IChatResponseModel, IExportableChatData, ISerializableChatData, ISerializableChatDataIn, ISerializableChatsData, ISerializedChatDataReference, normalizeSerializableChatData, toChatHistoryContent, updateRanges, ISerializableChatModelInputState } from '../model/chatModel.js'; import { ChatModelStore, IStartSessionProps } from '../model/chatModelStore.js'; @@ -663,13 +663,13 @@ export class ChatService extends Disposable implements IChatService { const parseAgentHostHistoryPrompt = (text: string, agent: IChatAgentData | undefined): IParsedChatRequest => { if (requestParser) { try { - const sessionCapabilities = this.chatSessionService.getCapabilitiesForSessionType(chatSessionType); + const attachmentCapabilities = this.getAttachmentCapabilitiesForParser(chatSessionType, agent); const parsed = requestParser.parseChatRequestWithReferences( EMPTY_REFERENCES, EMPTY_TOOL_ENABLEMENT_MAP, text, location, - { sessionType: chatSessionType, forcedAgent: agent, attachmentCapabilities: sessionCapabilities ?? agent?.capabilities }, + { sessionType: chatSessionType, forcedAgent: agent, attachmentCapabilities }, ); if (parsed.parts.length > 0) { return parsed; @@ -990,25 +990,37 @@ export class ChatService extends Disposable implements IChatService { } } + private getAttachmentCapabilitiesForParser(chatSessionType: string, agent: IChatAgentData | undefined): IChatAgentAttachmentCapabilities | undefined { + return this.chatSessionService.getCapabilitiesForSessionType(chatSessionType) ?? agent?.capabilities; + } + private parseChatRequest(sessionResource: URI, request: string, location: ChatAgentLocation, options: IChatSendRequestOptions | undefined): IParsedChatRequest { let parserContext = options?.parserContext; + let contextAgent = parserContext?.forcedAgent ?? parserContext?.selectedAgent; if (options?.agentId) { const agent = this.chatAgentService.getAgent(options.agentId); if (!agent) { throw new Error(`Unknown agent: ${options.agentId}`); } - parserContext = { selectedAgent: agent, mode: options.modeInfo?.kind }; + contextAgent = agent; + parserContext = { ...parserContext, selectedAgent: agent, mode: options.modeInfo?.kind }; const commandPart = options.slashCommand ? ` ${chatSubcommandLeader}${options.slashCommand}` : ''; request = `${chatAgentLeader}${agent.name}${commandPart} ${request}`; } else if (options?.agentIdSilent && !parserContext?.forcedAgent) { - // Resolve slash commandsin the context of locked participant so its subcommands take precedence over global + // Resolve slash commands in the context of locked participant so its subcommands take precedence over global // slash commands with the same name. const silentAgent = this.chatAgentService.getAgent(options.agentIdSilent); if (silentAgent) { + contextAgent = silentAgent; parserContext = { ...parserContext, forcedAgent: silentAgent }; } } + const attachmentCapabilities = parserContext?.attachmentCapabilities ?? this.getAttachmentCapabilitiesForParser(getChatSessionType(sessionResource), contextAgent); + if (attachmentCapabilities) { + parserContext = { ...parserContext, attachmentCapabilities }; + } + const parsedRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(sessionResource, request, location, parserContext); return parsedRequest; } diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 59001c7a24eb85..583eb528ea0d66 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -371,6 +371,14 @@ export interface ILanguageModelsService { getLanguageModelGroups(vendor: string): ILanguageModelsGroup[]; + /** + * Returns true if the given vendor's provider has completed at least one + * model resolution since registration. A `false` result indicates the + * vendor is still in a startup/reload race where its model list isn't yet + * authoritative — callers can fall back to a cached list in that case. + */ + hasResolvedVendor(vendor: string): boolean; + /** * Given a selector, returns a list of model identifiers * @param selector The selector to lookup for language models. If the selector is empty, all language models are returned. @@ -695,6 +703,7 @@ export class LanguageModelsService implements ILanguageModelsService { this._vendors.delete(item.vendor); this._providers.delete(item.vendor); this._clearModelCache(item.vendor); + this._modelsGroups.delete(item.vendor); removedVendorIds.push(item.vendor); } @@ -1006,6 +1015,10 @@ export class LanguageModelsService implements ILanguageModelsService { return this._modelsGroups.get(vendor) ?? []; } + hasResolvedVendor(vendor: string): boolean { + return this._modelsGroups.has(vendor); + } + async selectLanguageModels(selector: ILanguageModelChatSelector): Promise { if (selector.vendor) { @@ -1054,6 +1067,7 @@ export class LanguageModelsService implements ILanguageModelsService { return toDisposable(() => { this._logService.trace('[LM] UNregistered language model provider', vendor); this._clearModelCache(vendor); + this._modelsGroups.delete(vendor); this._providers.delete(vendor); modelChangeListener.dispose(); }); diff --git a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts index fb09d943baf26c..38f6e87a53c6e4 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts @@ -19,6 +19,8 @@ import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { INativeHostService } from '../../../../../platform/native/common/native.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IsSessionsWindowContext } from '../../../../common/contextkeys.js'; import { TitleBarLeadingActionsGroup } from '../../../../browser/parts/titlebar/titlebarActions.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; @@ -49,7 +51,9 @@ export class OpenWorkspaceInAgentsWindowAction extends Action2 { async run(accessor: ServicesAccessor) { const nativeHostService = accessor.get(INativeHostService); - await nativeHostService.openAgentsWindow(); + const workspaceContextService = accessor.get(IWorkspaceContextService); + const folderUri = workspaceContextService.getWorkspace().folders[0]?.uri; + await nativeHostService.openAgentsWindow({ folderUri: folderUri?.scheme === Schemas.file ? folderUri : undefined }); } } diff --git a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts index 307e6955d7a8b6..c7db9f9868c8d4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts @@ -148,6 +148,10 @@ class MockLanguageModelsService implements ILanguageModelsService { return this.modelGroups.get(vendor) || []; } + hasResolvedVendor(vendor: string): boolean { + return this.modelGroups.has(vendor); + } + async removeLanguageModelsProviderGroup(vendorId: string, providerGroupName: string): Promise { } diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionLogic.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionLogic.test.ts index b25023e98d6dfc..60b976a416de5d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionLogic.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionLogic.test.ts @@ -34,8 +34,9 @@ function computeAvailableModels( sessionType: string | undefined, currentModeKind: ChatModeKind, location: ChatAgentLocation, + resolvedVendors?: ReadonlySet, ): ILanguageModelChatMetadataAndIdentifier[] { - const merged = mergeModelsWithCache(liveModels, cachedModels, contributedVendors); + const merged = mergeModelsWithCache(liveModels, cachedModels, contributedVendors, resolvedVendors); merged.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name)); return filterModelsForSession(merged, sessionType, currentModeKind, location); } @@ -640,6 +641,67 @@ suite('ChatModelSelectionLogic', () => { assert.strictEqual(result.length, 2); assert.deepStrictEqual(result.map(m => m.metadata.vendor).sort(), ['vendor-a', 'vendor-b']); }); + + test('evicts cached entries for a resolved vendor that returned zero models (BYOK delete)', () => { + // vendor-a is resolved with one live model; vendor-b is resolved with no live models + // (e.g. the user removed their BYOK API key). Cached vendor-b entries must NOT + // resurrect those models in the picker. + const liveA = createModel('a-model', 'A Model', { vendor: 'vendor-a' }); + const staleB = createModel('b-model', 'B Model', { vendor: 'vendor-b' }); + const result = mergeModelsWithCache( + [liveA], + [staleB], + new Set(['vendor-a', 'vendor-b']), + new Set(['vendor-a', 'vendor-b']), + ); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].metadata.vendor, 'vendor-a'); + }); + + test('keeps cached entries for an unresolved vendor (extension reload race)', () => { + // vendor-b is contributed but its provider hasn't completed a resolution yet + // (e.g. extension is mid-reload). Cache must bridge the gap so the picker + // keeps showing the user's previously-seen models. + const liveA = createModel('a-model', 'A Model', { vendor: 'vendor-a' }); + const cachedB = createModel('b-model', 'B Model', { vendor: 'vendor-b' }); + const result = mergeModelsWithCache( + [liveA], + [cachedB], + new Set(['vendor-a', 'vendor-b']), + new Set(['vendor-a']), // vendor-b not yet resolved + ); + assert.strictEqual(result.length, 2); + assert.deepStrictEqual(result.map(m => m.metadata.vendor).sort(), ['vendor-a', 'vendor-b']); + }); + + test('evicts cache for a resolved vendor even when all live models are zero', () => { + // Edge case: the only resolved vendor returns zero models (user deleted all + // configurations). Cache must be ignored — the picker should be empty. + const stale = createModel('b-model', 'B Model', { vendor: 'vendor-b' }); + const result = mergeModelsWithCache( + [], + [stale], + new Set(['vendor-b']), + new Set(['vendor-b']), + ); + assert.strictEqual(result.length, 0); + }); + + test('preserves full cache when no vendors are contributed yet (startup race)', () => { + // During startup or an extension reload, vendor descriptors may not be + // registered yet. contributedVendors is empty and so is resolvedVendors. + // We must NOT drop the cache — that would reset the user's selected model + // before the vendors come back. + const cachedA = createModel('a-model', 'A Model', { vendor: 'vendor-a' }); + const cachedB = createModel('b-model', 'B Model', { vendor: 'vendor-b' }); + const result = mergeModelsWithCache( + [], + [cachedA, cachedB], + new Set(), + new Set(), + ); + assert.deepStrictEqual(result.map(m => m.metadata.id).sort(), ['a-model', 'b-model']); + }); }); suite('model switching scenarios', () => { @@ -1046,6 +1108,25 @@ suite('ChatModelSelectionLogic', () => { ); assert.deepStrictEqual(result.map(m => m.metadata.id), ['tool']); }); + + test('startup/extension reload with no contributors yet preserves cache (production path)', () => { + // Mirrors chatInputPart.getAllMergedModels at a moment when getVendors() + // is temporarily empty (extension host reloading). resolvedVendors is + // also empty because nothing has resolved. The picker must continue to + // show cached models so the user's selection isn't reset. + const cachedA = createModel('a-model', 'A Model', { vendor: 'vendor-a' }); + const cachedB = createModel('b-model', 'B Model', { vendor: 'vendor-b' }); + const result = computeAvailableModels( + [], + [cachedA, cachedB], + new Set(), + undefined, + ChatModeKind.Ask, + ChatAgentLocation.Chat, + new Set(), + ); + assert.deepStrictEqual(result.map(m => m.metadata.id).sort(), ['a-model', 'b-model']); + }); }); suite('_syncFromModel edge cases', () => { diff --git a/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts new file mode 100644 index 00000000000000..5141cbc5435755 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts @@ -0,0 +1,187 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IEntitlementsData } from '../../../../../base/common/defaultAccount.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { parseQuotas } from '../../../../services/chat/common/chatEntitlementService.js'; + +suite('parseQuotas', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function makeEntitlementsData(overrides: Partial): IEntitlementsData { + return { + access_type_sku: 'plus_monthly_subscriber_quota', + chat_enabled: true, + assigned_date: '2026-04-17T12:53:45-07:00', + can_signup_for_limited: false, + copilot_plan: 'individual_pro', + organization_login_list: [], + analytics_tracking_id: 'test', + ...overrides, + }; + } + + test('reads token_based_billing from top-level, not from quota snapshot', () => { + const data = makeEntitlementsData({ + token_based_billing: true, + quota_snapshots: { + premium_interactions: { + overage_count: 0, + overage_permitted: true, + percent_remaining: 97.4, + unlimited: false, + // no token_based_billing here — paid users don't have it per-snapshot + }, + }, + }); + + const quotas = parseQuotas(data); + assert.strictEqual(quotas.premiumChat?.usageBasedBilling, true); + }); + + test('usageBasedBilling is undefined when top-level token_based_billing is absent', () => { + const data = makeEntitlementsData({ + quota_snapshots: { + premium_interactions: { + overage_count: 0, + overage_permitted: false, + percent_remaining: 80, + unlimited: false, + }, + }, + }); + + const quotas = parseQuotas(data); + assert.strictEqual(quotas.premiumChat?.usageBasedBilling, undefined); + }); + + test('all quota types receive top-level token_based_billing', () => { + const data = makeEntitlementsData({ + token_based_billing: true, + quota_snapshots: { + chat: { + overage_count: 0, + overage_permitted: false, + percent_remaining: 100, + unlimited: true, + }, + completions: { + overage_count: 0, + overage_permitted: false, + percent_remaining: 100, + unlimited: true, + }, + premium_interactions: { + overage_count: 0, + overage_permitted: true, + percent_remaining: 97.4, + unlimited: false, + }, + }, + }); + + const quotas = parseQuotas(data); + assert.strictEqual(quotas.chat?.usageBasedBilling, true); + assert.strictEqual(quotas.completions?.usageBasedBilling, true); + assert.strictEqual(quotas.premiumChat?.usageBasedBilling, true); + }); + + test('parses paid user response correctly (top-level token_based_billing only)', () => { + const data = makeEntitlementsData({ + quota_reset_date: '2026-06-01', + quota_reset_date_utc: '2026-06-01T00:00:00.000Z', + token_based_billing: true, + quota_snapshots: { + chat: { + overage_count: 0, + overage_permitted: false, + percent_remaining: 100, + unlimited: true, + entitlement: '0', + }, + completions: { + overage_count: 0, + overage_permitted: false, + percent_remaining: 100, + unlimited: true, + entitlement: '0', + }, + premium_interactions: { + overage_count: 0, + overage_permitted: true, + percent_remaining: 97.4, + unlimited: false, + entitlement: '3900', + }, + }, + }); + + const quotas = parseQuotas(data); + assert.deepStrictEqual(quotas, { + resetDate: '2026-06-01T00:00:00.000Z', + resetDateHasTime: true, + chat: { + percentRemaining: 100, + unlimited: true, + usageBasedBilling: true, + resetAt: undefined, + entitlement: 0, + }, + completions: { + percentRemaining: 100, + unlimited: true, + usageBasedBilling: true, + resetAt: undefined, + entitlement: 0, + }, + premiumChat: { + percentRemaining: 97.4, + unlimited: false, + usageBasedBilling: true, + resetAt: undefined, + entitlement: 3900, + }, + additionalUsageEnabled: true, + additionalUsageCount: 0, + }); + }); + + test('parses free user CFI response with per-snapshot token_based_billing', () => { + const data = makeEntitlementsData({ + access_type_sku: 'free_limited_copilot', + copilot_plan: 'free', + token_based_billing: true, + quota_snapshots: { + chat: { + overage_count: 0, + overage_permitted: false, + percent_remaining: 98.7, + unlimited: false, + }, + completions: { + overage_count: 0, + overage_permitted: false, + percent_remaining: 100, + unlimited: false, + }, + premium_interactions: { + overage_count: 0, + overage_permitted: false, + percent_remaining: 0, + unlimited: false, + }, + }, + }); + + const quotas = parseQuotas(data); + assert.strictEqual(quotas.chat?.usageBasedBilling, true); + assert.strictEqual(quotas.completions?.usageBasedBilling, true); + assert.strictEqual(quotas.premiumChat?.usageBasedBilling, true); + assert.strictEqual(quotas.premiumChat?.percentRemaining, 0); + assert.strictEqual(quotas.additionalUsageEnabled, false); + }); +}); 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 9e8a7c6125a4e2..ce33bc69f5ff37 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 @@ -11,6 +11,7 @@ import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { constObservable, ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { mockObject } from '../../../../../../base/test/common/mock.js'; import { assertSnapshot } from '../../../../../../base/test/common/snapshot.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; @@ -59,6 +60,7 @@ import { MockChatService } from './mockChatService.js'; import { ChatSessionOptionsMap, IChatSession, IChatSessionHistoryItem, IChatSessionsService } from '../../../common/chatSessionsService.js'; import { MockChatSessionsService } from '../mockChatSessionsService.js'; import { AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING, COPILOT_SKILL_URI_SCHEME, TROUBLESHOOT_SKILL_PATH } from '../../../common/promptSyntax/promptTypes.js'; +import { ChatRequestSlashPromptPart } from '../../../common/requestParser/chatParserTypes.js'; const chatAgentWithUsedContextId = 'ChatProviderWithUsedContext'; const chatAgentWithUsedContext: IChatAgent = { @@ -187,6 +189,7 @@ suite('ChatService', () => { instantiationService.stub(IChatSlashCommandService, testDisposables.add(instantiationService.createInstance(ChatSlashCommandService))); instantiationService.stub(IConfigurationService, new TestConfigurationService()); instantiationService.stub(IChatService, new MockChatService()); + instantiationService.stub(IChatSessionsService, new MockChatSessionsService()); instantiationService.stub(IEnvironmentService, { workspaceStorageHome: URI.file('/test/path/to/workspaceStorage') }); instantiationService.stub(ILifecycleService, { onWillShutdown: Event.None }); instantiationService.stub(IWorkspaceEditingService, { onDidEnterWorkspace: Event.None }); @@ -1123,6 +1126,148 @@ suite('ChatService', () => { ] ); }); + + test('sendRequest passes agent host session capabilities to the request parser', async () => { + const sessionType = 'agent-host-copilot'; + const sessionResource = URI.from({ scheme: sessionType, path: '/session' }); + + const mockSessionsService = new MockChatSessionsService(); + mockSessionsService.setContributions([{ + type: sessionType, + name: 'Agent Host', + displayName: 'Agent Host', + description: 'Agent Host', + capabilities: { supportsPromptAttachments: true }, + }]); + testDisposables.add(mockSessionsService.registerChatSessionContentProvider(sessionType, { + provideChatSessionContent: resource => Promise.resolve({ + sessionResource: resource, + history: [], + onWillDispose: Event.None, + dispose: () => { }, + }), + })); + instantiationService.stub(IChatSessionsService, mockSessionsService); + + const promptsService = mockObject()({ _serviceBrand: undefined }); + promptsService.isValidSlashCommandName.callsFake((command: string) => command === 'skill'); + instantiationService.stub(IPromptsService, promptsService); + + testDisposables.add(chatAgentService.registerAgent(sessionType, { ...getAgentData(sessionType), isDefault: true })); + testDisposables.add(chatAgentService.registerAgentImplementation(sessionType, { async invoke() { return {}; } })); + + const testService = createChatService(); + const ref = await testService.acquireOrLoadSession(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref); + testDisposables.add(ref); + + const response = await testService.sendRequest(sessionResource, '/skill plan', { agentId: sessionType }); + ChatSendResult.assertSent(response); + await response.data.responseCompletePromise; + + const model = testService.getSession(sessionResource) as ChatModel; + assert.deepStrictEqual(model.getRequests()[0].message.parts.map(part => ({ + type: part.constructor.name, + text: part instanceof ChatRequestSlashPromptPart ? part.name : undefined, + })), [ + { type: 'ChatRequestAgentPart', text: undefined }, + { type: 'ChatRequestTextPart', text: undefined }, + { type: 'ChatRequestSlashPromptPart', text: 'skill' }, + { type: 'ChatRequestTextPart', text: undefined }, + ]); + }); + + test('sendRequest with agentIdSilent passes agent host session capabilities to the request parser', async () => { + const sessionType = 'agent-host-copilot'; + const sessionResource = URI.from({ scheme: sessionType, path: '/session-silent' }); + + const mockSessionsService = new MockChatSessionsService(); + mockSessionsService.setContributions([{ + type: sessionType, + name: 'Agent Host', + displayName: 'Agent Host', + description: 'Agent Host', + capabilities: { supportsPromptAttachments: true }, + }]); + testDisposables.add(mockSessionsService.registerChatSessionContentProvider(sessionType, { + provideChatSessionContent: resource => Promise.resolve({ + sessionResource: resource, + history: [], + onWillDispose: Event.None, + dispose: () => { }, + }), + })); + instantiationService.stub(IChatSessionsService, mockSessionsService); + + const promptsService = mockObject()({ _serviceBrand: undefined }); + promptsService.isValidSlashCommandName.callsFake((command: string) => command === 'skill'); + instantiationService.stub(IPromptsService, promptsService); + + testDisposables.add(chatAgentService.registerAgent(sessionType, { ...getAgentData(sessionType), isDefault: true })); + testDisposables.add(chatAgentService.registerAgentImplementation(sessionType, { async invoke() { return {}; } })); + + const testService = createChatService(); + const ref = await testService.acquireOrLoadSession(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref); + testDisposables.add(ref); + + const response = await testService.sendRequest(sessionResource, '/skill plan', { agentIdSilent: sessionType }); + ChatSendResult.assertSent(response); + await response.data.responseCompletePromise; + + const model = testService.getSession(sessionResource) as ChatModel; + assert.deepStrictEqual(model.getRequests()[0].message.parts.map(part => ({ + type: part.constructor.name, + text: part instanceof ChatRequestSlashPromptPart ? part.name : undefined, + })), [ + { type: 'ChatRequestSlashPromptPart', text: 'skill' }, + { type: 'ChatRequestTextPart', text: undefined }, + ]); + }); + + test('loadRemoteSession passes agent host session capabilities to the request parser', async () => { + const sessionType = 'agent-host-copilot'; + const sessionResource = URI.from({ scheme: sessionType, path: '/session-with-history' }); + + const mockSessionsService = new MockChatSessionsService(); + mockSessionsService.setContributions([{ + type: sessionType, + name: 'Agent Host', + displayName: 'Agent Host', + description: 'Agent Host', + capabilities: { supportsPromptAttachments: true }, + }]); + testDisposables.add(mockSessionsService.registerChatSessionContentProvider(sessionType, { + provideChatSessionContent: resource => Promise.resolve({ + sessionResource: resource, + history: [{ type: 'request', prompt: '/skill plan', participant: sessionType }], + onWillDispose: Event.None, + dispose: () => { }, + }), + })); + instantiationService.stub(IChatSessionsService, mockSessionsService); + + const promptsService = mockObject()({ _serviceBrand: undefined }); + promptsService.isValidSlashCommandName.callsFake((command: string) => command === 'skill'); + instantiationService.stub(IPromptsService, promptsService); + + testDisposables.add(chatAgentService.registerAgent(sessionType, { ...getAgentData(sessionType), isDefault: true })); + testDisposables.add(chatAgentService.registerAgentImplementation(sessionType, { async invoke() { return {}; } })); + + const testService = createChatService(); + const ref = await testService.acquireOrLoadSession(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref); + testDisposables.add(ref); + + assert.deepStrictEqual(ref.object.getRequests()[0].message.parts.map(part => ({ + type: part.constructor.name, + text: part instanceof ChatRequestSlashPromptPart ? part.name : undefined, + })), [ + { type: 'ChatRequestSlashPromptPart', text: 'skill' }, + { type: 'ChatRequestTextPart', text: undefined }, + ]); + }); + test('troubleshoot skill via attachedContext is blocked when fileLogging.enabled is off', async () => { const configService = instantiationService.get(IConfigurationService) as TestConfigurationService; await configService.setUserConfiguration(AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING, false); diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.ts index 51ac576ae1a850..4a8d2d4ea7f067 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.ts @@ -63,6 +63,10 @@ export class NullLanguageModelsService implements ILanguageModelsService { return []; } + hasResolvedVendor(vendor: string): boolean { + return false; + } + async selectLanguageModels(selector: ILanguageModelChatSelector): Promise { return []; } diff --git a/src/vs/workbench/contrib/customEditor/browser/customEditor.contribution.ts b/src/vs/workbench/contrib/customEditor/browser/customEditor.contribution.ts index ab9cf7ccf961a4..a02a6f3aef69bb 100644 --- a/src/vs/workbench/contrib/customEditor/browser/customEditor.contribution.ts +++ b/src/vs/workbench/contrib/customEditor/browser/customEditor.contribution.ts @@ -9,10 +9,11 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/editor.js'; import { WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; import { EditorExtensions, IEditorFactoryRegistry } from '../../../common/editor.js'; -import { ComplexCustomWorkingCopyEditorHandler, CustomEditorInputSerializer } from './customEditorInputFactory.js'; +import { ComplexCustomWorkingCopyEditorHandler, CustomEditorDiffInputSerializer, CustomEditorInputSerializer, CustomEditorSideBySideDiffInputSerializer } from './customEditorInputFactory.js'; import { ICustomEditorService } from '../common/customEditor.js'; import { WebviewEditor } from '../../webviewPanel/browser/webviewEditor.js'; import { CustomEditorInput } from './customEditorInput.js'; +import { CustomEditorDiffInput, CustomEditorSideBySideDiffInput } from './customEditorDiffInput.js'; import { CustomEditorService } from './customEditors.js'; registerSingleton(ICustomEditorService, CustomEditorService, InstantiationType.Delayed); @@ -24,10 +25,18 @@ Registry.as(EditorExtensions.EditorPane) WebviewEditor.ID, 'Webview Editor', ), [ - new SyncDescriptor(CustomEditorInput) + new SyncDescriptor(CustomEditorInput), + new SyncDescriptor(CustomEditorDiffInput), + new SyncDescriptor(CustomEditorSideBySideDiffInput), ]); Registry.as(EditorExtensions.EditorFactory) .registerEditorSerializer(CustomEditorInputSerializer.ID, CustomEditorInputSerializer); +Registry.as(EditorExtensions.EditorFactory) + .registerEditorSerializer(CustomEditorDiffInputSerializer.ID, CustomEditorDiffInputSerializer); + +Registry.as(EditorExtensions.EditorFactory) + .registerEditorSerializer(CustomEditorSideBySideDiffInputSerializer.ID, CustomEditorSideBySideDiffInputSerializer); + registerWorkbenchContribution2(ComplexCustomWorkingCopyEditorHandler.ID, ComplexCustomWorkingCopyEditorHandler, WorkbenchPhase.BlockStartup); diff --git a/src/vs/workbench/contrib/customEditor/browser/customEditorInput.ts b/src/vs/workbench/contrib/customEditor/browser/customEditorInput.ts index cef8490460b341..7072dfe39e2343 100644 --- a/src/vs/workbench/contrib/customEditor/browser/customEditorInput.ts +++ b/src/vs/workbench/contrib/customEditor/browser/customEditorInput.ts @@ -39,6 +39,7 @@ interface CustomEditorInputInitInfo { readonly resource: URI; readonly viewType: string; readonly webviewTitle: string | undefined; + readonly preferredName: string | undefined; readonly iconPath: WebviewIconPath | undefined; } @@ -109,7 +110,7 @@ export class CustomEditorInput extends LazilyResolvedWebviewEditorInput { @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @ICustomEditorLabelService private readonly customEditorLabelService: ICustomEditorLabelService, ) { - super({ providedId: init.viewType, viewType: init.viewType, name: '', iconPath: init.iconPath }, webview, themeService, webviewWorkbenchService); + super({ providedId: init.viewType, viewType: init.viewType, name: init.preferredName ?? '', iconPath: init.iconPath }, webview, themeService, webviewWorkbenchService); this._editorResource = init.resource; this.oldResource = options.oldResource; this._defaultDirtyState = options.startsDirty; @@ -166,14 +167,8 @@ export class CustomEditorInput extends LazilyResolvedWebviewEditorInput { capabilities |= EditorInputCapabilities.Singleton; } - if (this._modelRef) { - if (this._modelRef.object.isReadonly()) { - capabilities |= EditorInputCapabilities.Readonly; - } - } else { - if (this.filesConfigurationService.isReadonly(this.resource)) { - capabilities |= EditorInputCapabilities.Readonly; - } + if (this.isReadonly()) { + capabilities |= EditorInputCapabilities.Readonly; } if (this.resource.scheme === Schemas.untitled) { @@ -269,7 +264,7 @@ export class CustomEditorInput extends LazilyResolvedWebviewEditorInput { public override copy(): EditorInput { return CustomEditorInput.create(this.instantiationService, - { resource: this.resource, viewType: this.viewType, webviewTitle: this.getWebviewTitle(), iconPath: this.iconPath, }, + { resource: this.resource, viewType: this.viewType, webviewTitle: this.getWebviewTitle(), preferredName: undefined, iconPath: this.iconPath, }, this.group, this.webview.options); } diff --git a/src/vs/workbench/contrib/customEditor/browser/customEditorInputFactory.ts b/src/vs/workbench/contrib/customEditor/browser/customEditorInputFactory.ts index cb9da93c4714f8..3a5453e5e6f675 100644 --- a/src/vs/workbench/contrib/customEditor/browser/customEditorInputFactory.ts +++ b/src/vs/workbench/contrib/customEditor/browser/customEditorInputFactory.ts @@ -9,8 +9,10 @@ import { isEqual } from '../../../../base/common/resources.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { IEditorSerializer } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { CustomEditorInput } from './customEditorInput.js'; +import { CustomEditorDiffInput, CustomEditorSideBySideDiffInput, CustomEditorSideBySideDiffSide } from './customEditorDiffInput.js'; import { ICustomEditorService } from '../common/customEditor.js'; import { NotebookEditorInput } from '../../notebook/common/notebookEditorInput.js'; import { IWebviewService, WebviewContentOptions, WebviewContentPurpose, WebviewExtensionDescription, WebviewOptions } from '../../webview/browser/webview.js'; @@ -101,6 +103,7 @@ export class CustomEditorInputSerializer extends WebviewEditorInputSerializer { resource: data.editorResource, viewType: data.viewType, webviewTitle: data.title, + preferredName: undefined, iconPath: data.iconPath, }, webview, { startsDirty: data.dirty, backupId: data.backupId }); if (typeof data.group === 'number') { @@ -110,6 +113,106 @@ export class CustomEditorInputSerializer extends WebviewEditorInputSerializer { } } +interface SerializedCustomEditorDiff { + readonly originalResource: UriComponents; + readonly modifiedResource: UriComponents; + readonly viewType: string; + readonly label: string | undefined; + readonly description: string | undefined; + readonly dirty: boolean; +} + +export class CustomEditorDiffInputSerializer implements IEditorSerializer { + + public static readonly ID = CustomEditorDiffInput.typeId; + + public constructor( + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { } + + canSerialize(input: EditorInput): boolean { + return input instanceof CustomEditorDiffInput; + } + + serialize(input: CustomEditorDiffInput): string | undefined { + const data: SerializedCustomEditorDiff = { + originalResource: input.originalResource.toJSON(), + modifiedResource: input.modifiedResource.toJSON(), + viewType: input.viewType, + label: input.getName(), + description: input.getDescription(), + dirty: input.isDirty(), + }; + try { + return JSON.stringify(data); + } catch { + return undefined; + } + } + + deserialize(_instantiationService: IInstantiationService, serializedEditorInput: string): EditorInput { + const data: SerializedCustomEditorDiff = JSON.parse(serializedEditorInput); + return CustomEditorDiffInput.create(this._instantiationService, { + originalResource: URI.revive(data.originalResource), + modifiedResource: URI.revive(data.modifiedResource), + viewType: data.viewType, + label: data.label, + description: data.description, + iconPath: undefined, + }, undefined); + } +} + +interface SerializedCustomEditorSideBySideDiff extends SerializedCustomEditorDiff { + readonly diffId: string; + readonly side: CustomEditorSideBySideDiffSide; +} + +export class CustomEditorSideBySideDiffInputSerializer implements IEditorSerializer { + + public static readonly ID = CustomEditorSideBySideDiffInput.typeId; + + public constructor( + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { } + + canSerialize(input: EditorInput): boolean { + return input instanceof CustomEditorSideBySideDiffInput; + } + + serialize(input: CustomEditorSideBySideDiffInput): string | undefined { + const data: SerializedCustomEditorSideBySideDiff = { + originalResource: input.originalResource.toJSON(), + modifiedResource: input.modifiedResource.toJSON(), + viewType: input.viewType, + label: input.getName(), + description: input.getDescription(), + dirty: input.isDirty(), + diffId: input.diffId, + side: input.side, + }; + try { + return JSON.stringify(data); + } catch { + return undefined; + } + } + + deserialize(_instantiationService: IInstantiationService, serializedEditorInput: string): EditorInput { + const data: SerializedCustomEditorSideBySideDiff = JSON.parse(serializedEditorInput); + return CustomEditorSideBySideDiffInput.create(this._instantiationService, { + originalResource: URI.revive(data.originalResource), + modifiedResource: URI.revive(data.modifiedResource), + viewType: data.viewType, + label: data.label, + description: data.description, + iconPath: undefined, + diffId: data.diffId, + side: data.side, + }, undefined); + } +} + function reviveWebview(webviewService: IWebviewService, data: { origin: string | undefined; viewType: string; state: any; webviewOptions: WebviewOptions; contentOptions: WebviewContentOptions; extension?: WebviewExtensionDescription; title: string | undefined }) { const webview = webviewService.createWebviewOverlay({ providedViewType: data.viewType, @@ -202,6 +305,7 @@ export class ComplexCustomWorkingCopyEditorHandler extends Disposable implements resource: URI.revive(backupData.editorResource), viewType: backupData.viewType, webviewTitle: backupData.customTitle, + preferredName: undefined, iconPath: reviveWebviewIconPath(backupData.iconPath) }, webview, { backupId: backupData.backupId }); editor.updateGroup(0); diff --git a/src/vs/workbench/contrib/customEditor/browser/customEditors.ts b/src/vs/workbench/contrib/customEditor/browser/customEditors.ts index d9106e60e11f44..905e06f941ee48 100644 --- a/src/vs/workbench/contrib/customEditor/browser/customEditors.ts +++ b/src/vs/workbench/contrib/customEditor/browser/customEditors.ts @@ -182,11 +182,11 @@ export class CustomEditorService extends Disposable implements ICustomEditorServ singlePerResource: () => !(this.getCustomEditorCapabilities(contributedEditor.id)?.supportsMultipleEditorsPerDocument ?? false) }, { - createEditorInput: ({ resource }, group) => { - return { editor: CustomEditorInput.create(this.instantiationService, { resource, viewType: contributedEditor.id, webviewTitle: undefined, iconPath: undefined }, group.id) }; + createEditorInput: ({ resource, label }, group) => { + return { editor: CustomEditorInput.create(this.instantiationService, { resource, viewType: contributedEditor.id, webviewTitle: undefined, preferredName: label, iconPath: undefined }, group.id) }; }, createUntitledEditorInput: ({ resource }, group) => { - return { editor: CustomEditorInput.create(this.instantiationService, { resource: resource ?? URI.from({ scheme: Schemas.untitled, authority: `Untitled-${this._untitledCounter++}` }), viewType: contributedEditor.id, webviewTitle: undefined, iconPath: undefined }, group.id) }; + return { editor: CustomEditorInput.create(this.instantiationService, { resource: resource ?? URI.from({ scheme: Schemas.untitled, authority: `Untitled-${this._untitledCounter++}` }), viewType: contributedEditor.id, webviewTitle: undefined, preferredName: undefined, iconPath: undefined }, group.id) }; }, createDiffEditorInput: async (diffEditorInput, group) => { await this.extensionService.activateByEvent(`onCustomEditor:${contributedEditor.id}`); @@ -243,8 +243,8 @@ export class CustomEditorService extends Disposable implements ICustomEditorServ return this.instantiationService.createInstance(DiffEditorInput, editor.label, editor.description, originalOverride, modifiedOverride, true); } - const modifiedOverride = CustomEditorInput.create(this.instantiationService, { resource: modifiedResource, viewType: contributedEditor.id, webviewTitle: undefined, iconPath: undefined }, group.id, { customClasses: 'modified' }); - const originalOverride = CustomEditorInput.create(this.instantiationService, { resource: originalResource, viewType: contributedEditor.id, webviewTitle: undefined, iconPath: undefined }, group.id, { customClasses: 'original' }); + const modifiedOverride = CustomEditorInput.create(this.instantiationService, { resource: modifiedResource, viewType: contributedEditor.id, webviewTitle: undefined, preferredName: undefined, iconPath: undefined }, group.id, { customClasses: 'modified' }); + const originalOverride = CustomEditorInput.create(this.instantiationService, { resource: originalResource, viewType: contributedEditor.id, webviewTitle: undefined, preferredName: undefined, iconPath: undefined }, group.id, { customClasses: 'original' }); return this.instantiationService.createInstance(DiffEditorInput, editor.label, editor.description, originalOverride, modifiedOverride, true); } @@ -437,7 +437,7 @@ export class CustomEditorService extends Disposable implements ICustomEditorServ let replacement: EditorInput | IResourceEditorInput; if (possibleEditors.defaultEditor) { const viewType = possibleEditors.defaultEditor.id; - replacement = CustomEditorInput.create(this.instantiationService, { resource: newResource, viewType, webviewTitle: undefined, iconPath: undefined }, group); + replacement = CustomEditorInput.create(this.instantiationService, { resource: newResource, viewType, webviewTitle: undefined, preferredName: undefined, iconPath: undefined }, group); } else { replacement = { resource: newResource, options: { override: DEFAULT_EDITOR_ASSOCIATION.id } }; } diff --git a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts index 69bec710f502ac..b60f49efcf557b 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts @@ -163,7 +163,8 @@ Registry.as(ConfigurationExtensions.Configuration) 'extensions.ignoreRecommendations': { type: 'boolean', description: localize('extensionsIgnoreRecommendations', "When enabled, the notifications for extension recommendations will not be shown."), - default: false + default: false, + agentsWindow: { default: true, readOnly: true }, }, 'extensions.showRecommendationsOnlyOnDemand': { type: 'boolean', diff --git a/src/vs/workbench/contrib/files/browser/files.contribution.ts b/src/vs/workbench/contrib/files/browser/files.contribution.ts index c0b8db32da012d..574c2d8e5d406f 100644 --- a/src/vs/workbench/contrib/files/browser/files.contribution.ts +++ b/src/vs/workbench/contrib/files/browser/files.contribution.ts @@ -269,7 +269,8 @@ configurationRegistry.registerConfiguration({ ], 'default': isWeb ? AutoSaveConfiguration.AFTER_DELAY : AutoSaveConfiguration.OFF, 'markdownDescription': nls.localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'autoSave' }, "Controls [auto save](https://code.visualstudio.com/docs/editor/codebasics#_save-auto-save) of editors that have unsaved changes.", AutoSaveConfiguration.OFF, AutoSaveConfiguration.AFTER_DELAY, AutoSaveConfiguration.ON_FOCUS_CHANGE, AutoSaveConfiguration.ON_WINDOW_CHANGE, AutoSaveConfiguration.AFTER_DELAY), - scope: ConfigurationScope.LANGUAGE_OVERRIDABLE + scope: ConfigurationScope.LANGUAGE_OVERRIDABLE, + agentsWindow: { default: 'afterDelay' }, }, 'files.autoSaveDelay': { 'type': 'number', diff --git a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts index 6938c346c6b32b..e466989e7c2ce5 100644 --- a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts +++ b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts @@ -45,7 +45,8 @@ Registry.as(Extensions.Configuration).registerConfigurat experiment: { mode: 'auto' }, - tags: ['experimental'] + tags: ['experimental'], + agentsWindow: { default: 'editor' }, }, [InlineChatConfigKeys.FixDiagnostics]: { description: localize('fixDiagnostics', "Controls whether the Fix action is shown for diagnostics in the editor."), diff --git a/src/vs/workbench/contrib/markers/browser/markers.contribution.ts b/src/vs/workbench/contrib/markers/browser/markers.contribution.ts index 8c7055f1534793..43a883c508d202 100644 --- a/src/vs/workbench/contrib/markers/browser/markers.contribution.ts +++ b/src/vs/workbench/contrib/markers/browser/markers.contribution.ts @@ -23,7 +23,7 @@ import { IClipboardService } from '../../../../platform/clipboard/common/clipboa import { Disposable, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { IStatusbarEntryAccessor, IStatusbarService, StatusbarAlignment, IStatusbarEntry } from '../../../services/statusbar/browser/statusbar.js'; import { IMarkerService, MarkerStatistics } from '../../../../platform/markers/common/markers.js'; -import { ViewContainer, IViewContainersRegistry, Extensions as ViewContainerExtensions, ViewContainerLocation, IViewsRegistry } from '../../../common/views.js'; +import { ViewContainer, IViewContainersRegistry, Extensions as ViewContainerExtensions, ViewContainerLocation, IViewsRegistry, WindowEnablement } from '../../../common/views.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; import { getVisbileViewContextKey, FocusedViewContext } from '../../../common/contextkeys.js'; import { ViewPaneContainer } from '../../../browser/parts/views/viewPaneContainer.js'; @@ -137,6 +137,7 @@ const VIEW_CONTAINER: ViewContainer = Registry.as(ViewC order: 0, ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [Markers.MARKERS_CONTAINER_ID, { mergeViewWithContainerWhenSingleView: true }]), storageId: Markers.MARKERS_VIEW_STORAGE_ID, + windowEnablement: WindowEnablement.Both }, ViewContainerLocation.Panel, { doNotRegisterOpenCommand: true }); Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews([{ @@ -151,7 +152,8 @@ Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews mnemonicTitle: localize({ key: 'miMarker', comment: ['&& denotes a mnemonic'] }, "&&Problems"), keybindings: { primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyM }, order: 0, - } + }, + windowEnablement: WindowEnablement.Both }], VIEW_CONTAINER); // workbench diff --git a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts index 4855acd4a3debc..c754843a640750 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts @@ -61,8 +61,9 @@ import { nullRange, Settings2EditorModel } from '../../../services/preferences/c import { IUserDataProfileService } from '../../../services/userDataProfile/common/userDataProfile.js'; import { IUserDataSyncWorkbenchService } from '../../../services/userDataSync/common/userDataSync.js'; import { SuggestEnabledInput } from '../../codeEditor/browser/suggestEnabledInput/suggestEnabledInput.js'; -import { ADVANCED_SETTING_TAG, CONTEXT_AI_SETTING_RESULTS_AVAILABLE, CONTEXT_SETTINGS_EDITOR, CONTEXT_SETTINGS_ROW_FOCUS, CONTEXT_SETTINGS_SEARCH_FOCUS, CONTEXT_TOC_ROW_FOCUS, EMBEDDINGS_SEARCH_PROVIDER_NAME, ENABLE_LANGUAGE_FILTER, EXTENSION_FETCH_TIMEOUT_MS, EXTENSION_SETTING_TAG, FEATURE_SETTING_TAG, FILTER_MODEL_SEARCH_PROVIDER_NAME, getExperimentalExtensionToggleData, ID_SETTING_TAG, IPreferencesSearchService, ISearchProvider, LANGUAGE_SETTING_TAG, LLM_RANKED_SEARCH_PROVIDER_NAME, MODIFIED_SETTING_TAG, POLICY_SETTING_TAG, REQUIRE_TRUSTED_WORKSPACE_SETTING_TAG, SETTINGS_EDITOR_COMMAND_CLEAR_SEARCH_RESULTS, SETTINGS_EDITOR_COMMAND_SHOW_AI_RESULTS, SETTINGS_EDITOR_COMMAND_SUGGEST_FILTERS, SETTINGS_EDITOR_COMMAND_TOGGLE_AI_SEARCH, STRING_MATCH_SEARCH_PROVIDER_NAME, TF_IDF_SEARCH_PROVIDER_NAME, WorkbenchSettingsEditorSettings, WORKSPACE_TRUST_SETTING_TAG } from '../common/preferences.js'; +import { ADVANCED_SETTING_TAG, AGENTS_WINDOW_SETTING_TAG, CONTEXT_AI_SETTING_RESULTS_AVAILABLE, CONTEXT_SETTINGS_EDITOR, CONTEXT_SETTINGS_ROW_FOCUS, CONTEXT_SETTINGS_SEARCH_FOCUS, CONTEXT_TOC_ROW_FOCUS, EMBEDDINGS_SEARCH_PROVIDER_NAME, ENABLE_LANGUAGE_FILTER, EXTENSION_FETCH_TIMEOUT_MS, EXTENSION_SETTING_TAG, FEATURE_SETTING_TAG, FILTER_MODEL_SEARCH_PROVIDER_NAME, getExperimentalExtensionToggleData, ID_SETTING_TAG, IPreferencesSearchService, ISearchProvider, LANGUAGE_SETTING_TAG, LLM_RANKED_SEARCH_PROVIDER_NAME, MODIFIED_SETTING_TAG, POLICY_SETTING_TAG, REQUIRE_TRUSTED_WORKSPACE_SETTING_TAG, SETTINGS_EDITOR_COMMAND_CLEAR_SEARCH_RESULTS, SETTINGS_EDITOR_COMMAND_SHOW_AI_RESULTS, SETTINGS_EDITOR_COMMAND_SUGGEST_FILTERS, SETTINGS_EDITOR_COMMAND_TOGGLE_AI_SEARCH, STRING_MATCH_SEARCH_PROVIDER_NAME, TF_IDF_SEARCH_PROVIDER_NAME, WorkbenchSettingsEditorSettings, WORKSPACE_TRUST_SETTING_TAG } from '../common/preferences.js'; import { settingsHeaderBorder, settingsSashBorder, settingsTextInputBorder } from '../common/settingsEditorColorRegistry.js'; +import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import './media/settingsEditor2.css'; import { preferencesAiResultsIcon, preferencesClearInputIcon, preferencesFilterIcon } from './preferencesIcons.js'; import { SettingsTarget, SettingsTargetsWidget } from './preferencesWidgets.js'; @@ -262,7 +263,8 @@ export class SettingsEditor2 extends EditorPane { @IEditorProgressService private readonly editorProgressService: IEditorProgressService, @IUserDataProfileService userDataProfileService: IUserDataProfileService, @IKeybindingService private readonly keybindingService: IKeybindingService, - @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService + @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, ) { super(SettingsEditor2.ID, group, telemetryService, themeService, storageService); this.searchDelayer = this._register(new Delayer(200)); @@ -336,6 +338,9 @@ export class SettingsEditor2 extends EditorPane { if (ENABLE_LANGUAGE_FILTER && !SettingsEditor2.SUGGESTIONS.includes(`@${LANGUAGE_SETTING_TAG}`)) { SettingsEditor2.SUGGESTIONS.push(`@${LANGUAGE_SETTING_TAG}`); } + if (this.environmentService.isSessionsWindow && !SettingsEditor2.SUGGESTIONS.includes(`@${AGENTS_WINDOW_SETTING_TAG}`)) { + SettingsEditor2.SUGGESTIONS.push(`@${AGENTS_WINDOW_SETTING_TAG}`); + } this.inputChangeListener = this._register(new MutableDisposable()); } diff --git a/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts b/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts index 35a683edd7f58b..39ee5f260ed834 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditorSettingIndicators.ts @@ -369,6 +369,16 @@ export class SettingsTreeIndicatorsLabel implements IDisposable { } }], }), { setupKeyboardEvents: true })); + } else if (element.isAgentsWindowReadOnly) { + this.scopeOverridesIndicator.element.style.display = 'inline'; + this.scopeOverridesIndicator.element.classList.add('setting-indicator'); + + this.scopeOverridesIndicator.label.text = '$(lock) ' + localize('agentsWindowReadOnlyLabelText', "Cannot be changed in Agents window"); + const content = localize('agentsWindowReadOnlyDescription', "This setting cannot be changed in the Agents window."); + this.scopeOverridesIndicator.disposables.add(this.hoverService.setupDelayedHover(this.scopeOverridesIndicator.element, { + ...this.defaultHoverOptions, + content, + }, { setupKeyboardEvents: true })); } else if (element.settingsTarget === ConfigurationTarget.USER_LOCAL && this.configurationService.isSettingAppliedForAllProfiles(element.setting.key)) { this.scopeOverridesIndicator.element.style.display = 'inline'; this.scopeOverridesIndicator.element.classList.add('setting-indicator'); @@ -564,6 +574,8 @@ export function getIndicatorsLabelAriaLabel(element: SettingsTreeSettingElement, if (element.hasPolicyValue) { ariaLabelSections.push(localize('policyDescriptionAccessible', "Managed by organization policy; setting value not applied")); + } else if (element.isAgentsWindowReadOnly) { + ariaLabelSections.push(localize('agentsWindowReadOnlyAccessible', "Cannot be changed in Agents window")); } else if (element.settingsTarget === ConfigurationTarget.USER_LOCAL && configurationService.isSettingAppliedForAllProfiles(element.setting.key)) { ariaLabelSections.push(localize('applicationSettingDescriptionAccessible', "Setting value retained when switching profiles")); } else { diff --git a/src/vs/workbench/contrib/preferences/browser/settingsTree.ts b/src/vs/workbench/contrib/preferences/browser/settingsTree.ts index f2b32036abb1b0..20eb3f981ca780 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsTree.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsTree.ts @@ -176,7 +176,7 @@ function getObjectDisplayValue(element: SettingsTreeSettingElement): IObjectData const data = element.isConfigured ? { ...elementDefaultValue, ...elementScopeValue } : - element.hasPolicyValue ? element.scopeValue : + element.hasPolicyValue || element.isAgentsWindowReadOnly ? element.scopeValue : elementDefaultValue; const { objectProperties, objectPatternProperties, objectAdditionalProperties } = element.setting; @@ -1313,7 +1313,7 @@ class SettingComplexObjectRenderer extends SettingComplexRenderer implements ITr showAddButton: false, isReadOnly: true, }); - template.button.parentElement?.classList.toggle('hide', dataElement.hasPolicyValue); + template.button.parentElement?.classList.toggle('hide', dataElement.hasPolicyValue || dataElement.isAgentsWindowReadOnly); super.renderValue(dataElement, template, onChange); } } @@ -1735,7 +1735,7 @@ abstract class SettingIncludeExcludeRenderer extends AbstractSettingRenderer imp protected renderValue(dataElement: SettingsTreeSettingElement, template: ISettingIncludeExcludeItemTemplate, onChange: (value: string) => void): void { const value = getIncludeExcludeDisplayValue(dataElement); - template.includeExcludeWidget.setValue(value, { isReadOnly: dataElement.hasPolicyValue }); + template.includeExcludeWidget.setValue(value, { isReadOnly: dataElement.hasPolicyValue || dataElement.isAgentsWindowReadOnly }); template.context = dataElement; template.elementDisposables.add(toDisposable(() => { template.includeExcludeWidget.cancelEdit(); @@ -1806,7 +1806,7 @@ abstract class AbstractSettingTextRenderer extends AbstractSettingRenderer imple protected renderValue(dataElement: SettingsTreeSettingElement, template: ISettingTextItemTemplate, onChange: (value: string) => void): void { template.onChange = undefined; template.inputBox.value = dataElement.value; - template.inputBox.setEnabled(!dataElement.hasPolicyValue); + template.inputBox.setEnabled(!dataElement.hasPolicyValue && !dataElement.isAgentsWindowReadOnly); template.inputBox.setAriaLabel(dataElement.setting.key); template.onChange = value => { if (!renderValidations(dataElement, template, false)) { @@ -1956,7 +1956,7 @@ class SettingEnumRenderer extends AbstractSettingRenderer implements ITreeRender template.selectBox.setOptions(displayOptions); template.selectBox.setAriaLabel(dataElement.setting.key); - template.selectBox.setEnabled(!dataElement.hasPolicyValue); + template.selectBox.setEnabled(!dataElement.hasPolicyValue && !dataElement.isAgentsWindowReadOnly); let idx = settingEnum.indexOf(dataElement.value); if (idx === -1) { @@ -2027,7 +2027,7 @@ class SettingNumberRenderer extends AbstractSettingRenderer implements ITreeRend dataElement.value.toString() : ''; template.inputBox.step = dataElement.valueType.includes('integer') ? '1' : 'any'; template.inputBox.setAriaLabel(dataElement.setting.key); - template.inputBox.setEnabled(!dataElement.hasPolicyValue); + template.inputBox.setEnabled(!dataElement.hasPolicyValue && !dataElement.isAgentsWindowReadOnly); template.onChange = value => { if (!renderValidations(dataElement, template, false)) { onChange(nullNumParseFn(value)); @@ -2110,7 +2110,7 @@ class SettingBoolRenderer extends AbstractSettingRenderer implements ITreeRender protected renderValue(dataElement: SettingsTreeSettingElement, template: ISettingBoolItemTemplate, onChange: (value: boolean) => void): void { template.onChange = undefined; template.checkbox.checked = dataElement.value; - if (dataElement.hasPolicyValue) { + if (dataElement.hasPolicyValue || dataElement.isAgentsWindowReadOnly) { template.checkbox.disable(); template.descriptionElement.classList.add('disabled'); } else { diff --git a/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts b/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts index eaf1dfe189a06a..df7fb9e9c47100 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts @@ -20,7 +20,7 @@ import { APPLICATION_SCOPES, FOLDER_SCOPES, IWorkbenchConfigurationService, LOCA import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { IExtensionSetting, ISearchResult, ISetting, ISettingMatch, SettingMatchType, SettingValueType } from '../../../services/preferences/common/preferences.js'; import { IUserDataProfileService } from '../../../services/userDataProfile/common/userDataProfile.js'; -import { ENABLE_EXTENSION_TOGGLE_SETTINGS, ENABLE_LANGUAGE_FILTER, MODIFIED_SETTING_TAG, POLICY_SETTING_TAG, REQUIRE_TRUSTED_WORKSPACE_SETTING_TAG, compareTwoNullableNumbers, wordifyKey } from '../common/preferences.js'; +import { AGENTS_WINDOW_SETTING_TAG, ENABLE_EXTENSION_TOGGLE_SETTINGS, ENABLE_LANGUAGE_FILTER, MODIFIED_SETTING_TAG, POLICY_SETTING_TAG, REQUIRE_TRUSTED_WORKSPACE_SETTING_TAG, compareTwoNullableNumbers, wordifyKey } from '../common/preferences.js'; import { SettingsTarget } from './preferencesWidgets.js'; import { ITOCEntry, tocData } from './settingsLayout.js'; @@ -154,6 +154,11 @@ export class SettingsTreeSettingElement extends SettingsTreeElement { */ hasPolicyValue = false; + /** + * Whether the setting is read-only in the Agents window. + */ + isAgentsWindowReadOnly = false; + tags?: Set; overriddenScopeList: string[] = []; overriddenDefaultsLanguageList: string[] = []; @@ -176,6 +181,7 @@ export class SettingsTreeSettingElement extends SettingsTreeElement { private readonly productService: IProductService, private readonly userDataProfileService: IUserDataProfileService, private readonly configurationService: IWorkbenchConfigurationService, + private readonly isSessionsWindow: boolean, ) { super(sanitizeId(parent.id + '_' + setting.key)); this.setting = setting; @@ -368,9 +374,19 @@ export class SettingsTreeSettingElement extends SettingsTreeElement { this.defaultValue = inspected.defaultValue; } + let hasAgentsWindowOverride = false; + if (this.isSessionsWindow) { + const property = Registry.as(Extensions.Configuration).getConfigurationProperties()[this.setting.key]; + hasAgentsWindowOverride = !!property?.agentsWindow; + this.isAgentsWindowReadOnly = !!property?.agentsWindow?.readOnly; + if (this.isAgentsWindowReadOnly) { + isConfigured = false; + } + } + this.value = displayValue; this.isConfigured = isConfigured; - if (isConfigured || this.setting.tags || this.tags || this.setting.restricted || this.hasPolicyValue) { + if (isConfigured || this.setting.tags || this.tags || this.setting.restricted || this.hasPolicyValue || hasAgentsWindowOverride) { // Don't create an empty Set for all 1000 settings, only if needed this.tags = new Set(); if (isConfigured) { @@ -386,6 +402,10 @@ export class SettingsTreeSettingElement extends SettingsTreeElement { if (this.hasPolicyValue) { this.tags.add(POLICY_SETTING_TAG); } + + if (hasAgentsWindowOverride) { + this.tags.add(AGENTS_WINDOW_SETTING_TAG); + } } } @@ -570,7 +590,8 @@ export class SettingsTreeModel implements IDisposable { @IWorkbenchConfigurationService private readonly _configurationService: IWorkbenchConfigurationService, @ILanguageService private readonly _languageService: ILanguageService, @IUserDataProfileService private readonly _userDataProfileService: IUserDataProfileService, - @IProductService private readonly _productService: IProductService + @IProductService private readonly _productService: IProductService, + @IWorkbenchEnvironmentService private readonly _environmentService: IWorkbenchEnvironmentService, ) { } @@ -686,7 +707,8 @@ export class SettingsTreeModel implements IDisposable { this._languageService, this._productService, this._userDataProfileService, - this._configurationService); + this._configurationService, + this._environmentService.isSessionsWindow); const nameElements = this._treeElementsBySettingName.get(setting.key) ?? []; nameElements.push(element); @@ -986,7 +1008,7 @@ export class SearchResultModel extends SettingsTreeModel { @IUserDataProfileService userDataProfileService: IUserDataProfileService, @IProductService productService: IProductService ) { - super(viewState, isWorkspaceTrusted, configurationService, languageService, userDataProfileService, productService); + super(viewState, isWorkspaceTrusted, configurationService, languageService, userDataProfileService, productService, environmentService); this.settingsOrderByTocIndex = settingsOrderByTocIndex; this.cachedUniqueSearchResults = new Map(); this.update({ id: 'searchResultModel', label: '' }); @@ -1217,6 +1239,11 @@ export function parseQuery(query: string): IParsedQuery { return ''; }); + query = query.replace(`@${AGENTS_WINDOW_SETTING_TAG}`, () => { + tags.push(AGENTS_WINDOW_SETTING_TAG); + return ''; + }); + // Handle @stable by excluding preview and experimental tags query = query.replace(/@stable/g, () => { tags.push('stable'); diff --git a/src/vs/workbench/contrib/preferences/common/preferences.ts b/src/vs/workbench/contrib/preferences/common/preferences.ts index 1e6fcb8202723b..2e97fb770e8d09 100644 --- a/src/vs/workbench/contrib/preferences/common/preferences.ts +++ b/src/vs/workbench/contrib/preferences/common/preferences.ts @@ -105,6 +105,7 @@ export const ID_SETTING_TAG = 'id:'; export const LANGUAGE_SETTING_TAG = 'lang:'; export const GENERAL_TAG_SETTING_TAG = 'tag:'; export const POLICY_SETTING_TAG = 'hasPolicy'; +export const AGENTS_WINDOW_SETTING_TAG = 'override:agentsWindow'; export const WORKSPACE_TRUST_SETTING_TAG = 'workspaceTrust'; export const REQUIRE_TRUSTED_WORKSPACE_SETTING_TAG = 'requireTrustedWorkspace'; export const ADVANCED_SETTING_TAG = 'advanced'; diff --git a/src/vs/workbench/contrib/search/browser/search.common.contribution.ts b/src/vs/workbench/contrib/search/browser/search.common.contribution.ts new file mode 100644 index 00000000000000..c729062eb3cbe7 --- /dev/null +++ b/src/vs/workbench/contrib/search/browser/search.common.contribution.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Common search service registrations shared between the main workbench and the Agents window. + +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { registerContributions as replaceContributions } from './replaceContributions.js'; +import { registerContributions as notebookSearchContributions } from './notebookSearch/notebookSearchContributions.js'; +import { ISearchHistoryService, SearchHistoryService } from '../common/searchHistoryService.js'; + +replaceContributions(); +notebookSearchContributions(); +registerSingleton(ISearchHistoryService, SearchHistoryService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/search/browser/search.contribution.ts b/src/vs/workbench/contrib/search/browser/search.contribution.ts index 86d572b1aba912..b1c651ee10c6d0 100644 --- a/src/vs/workbench/contrib/search/browser/search.contribution.ts +++ b/src/vs/workbench/contrib/search/browser/search.contribution.ts @@ -14,12 +14,9 @@ import { Extensions as QuickAccessExtensions, IQuickAccessRegistry } from '../.. import { Registry } from '../../../../platform/registry/common/platform.js'; import { ViewPaneContainer } from '../../../browser/parts/views/viewPaneContainer.js'; import { Extensions as ViewExtensions, IViewContainersRegistry, IViewDescriptor, IViewsRegistry, ViewContainerLocation } from '../../../common/views.js'; -import { registerContributions as replaceContributions } from './replaceContributions.js'; -import { registerContributions as notebookSearchContributions } from './notebookSearch/notebookSearchContributions.js'; import { searchViewIcon } from './searchIcons.js'; import { SearchView } from './searchView.js'; import { registerContributions as searchWidgetContributions } from './searchWidget.js'; -import { ISearchHistoryService, SearchHistoryService } from '../common/searchHistoryService.js'; import { SearchViewModelWorkbenchService } from './searchTreeModel/searchModel.js'; import { ISearchViewModelWorkbenchService } from './searchTreeModel/searchViewModelWorkbenchService.js'; import { SearchSortOrder, SEARCH_EXCLUDE_CONFIG, VIEWLET_ID, ViewMode, VIEW_ID, DEFAULT_MAX_SEARCH_RESULTS, SemanticSearchBehavior } from '../../../services/search/common/search.js'; @@ -36,6 +33,7 @@ import './searchActionsRemoveReplace.js'; import './searchActionsTopBar.js'; import './searchActionsTextQuickAccess.js'; import './searchQuickAccess.contribution.js'; +import './search.common.contribution.js'; import { TEXT_SEARCH_QUICK_ACCESS_PREFIX, TextSearchQuickAccess } from './quickTextSearch/textSearchQuickAccess.js'; import { Extensions, IConfigurationMigrationRegistry } from '../../../common/configuration.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; @@ -43,10 +41,7 @@ import { AccessibleViewRegistry } from '../../../../platform/accessibility/brows import { SearchAccessibilityHelp } from './searchAccessibilityHelp.js'; registerSingleton(ISearchViewModelWorkbenchService, SearchViewModelWorkbenchService, InstantiationType.Delayed); -registerSingleton(ISearchHistoryService, SearchHistoryService, InstantiationType.Delayed); -replaceContributions(); -notebookSearchContributions(); searchWidgetContributions(); registerWorkbenchContribution2(SearchChatContextContribution.ID, SearchChatContextContribution, WorkbenchPhase.AfterRestored); @@ -190,7 +185,8 @@ configurationRegistry.registerConfiguration({ 'search.quickOpen.includeHistory': { type: 'boolean', description: nls.localize('search.quickOpen.includeHistory', "Whether to include results from recently opened files in the file results for Quick Open."), - default: true + default: true, + agentsWindow: { default: false }, }, 'search.quickOpen.history.filterSortOrder': { type: 'string', @@ -287,42 +283,6 @@ configurationRegistry.registerConfiguration({ default: 300, markdownDescription: nls.localize('search.searchOnTypeDebouncePeriod', "When {0} is enabled, controls the timeout in milliseconds between a character being typed and the search starting. Has no effect when {0} is disabled.", '`#search.searchOnType#`') }, - 'search.searchEditor.doubleClickBehaviour': { - type: 'string', - enum: ['selectWord', 'goToLocation', 'openLocationToSide'], - default: 'goToLocation', - enumDescriptions: [ - nls.localize('search.searchEditor.doubleClickBehaviour.selectWord', "Double-clicking selects the word under the cursor."), - nls.localize('search.searchEditor.doubleClickBehaviour.goToLocation', "Double-clicking opens the result in the active editor group."), - nls.localize('search.searchEditor.doubleClickBehaviour.openLocationToSide', "Double-clicking opens the result in the editor group to the side, creating one if it does not yet exist."), - ], - markdownDescription: nls.localize('search.searchEditor.doubleClickBehaviour', "Configure effect of double-clicking a result in a search editor.") - }, - 'search.searchEditor.singleClickBehaviour': { - type: 'string', - enum: ['default', 'peekDefinition',], - default: 'default', - enumDescriptions: [ - nls.localize('search.searchEditor.singleClickBehaviour.default', "Single-clicking does nothing."), - nls.localize('search.searchEditor.singleClickBehaviour.peekDefinition', "Single-clicking opens a Peek Definition window."), - ], - markdownDescription: nls.localize('search.searchEditor.singleClickBehaviour', "Configure effect of single-clicking a result in a search editor.") - }, - 'search.searchEditor.reusePriorSearchConfiguration': { - type: 'boolean', - default: false, - markdownDescription: nls.localize({ key: 'search.searchEditor.reusePriorSearchConfiguration', comment: ['"Search Editor" is a type of editor that can display search results. "includes, excludes, and flags" refers to the "files to include" and "files to exclude" input boxes, and the flags that control whether a query is case-sensitive or a regex.'] }, "When enabled, new Search Editors will reuse the includes, excludes, and flags of the previously opened Search Editor.") - }, - 'search.searchEditor.defaultNumberOfContextLines': { - type: ['number', 'null'], - default: 1, - markdownDescription: nls.localize('search.searchEditor.defaultNumberOfContextLines', "The default number of surrounding context lines to use when creating new Search Editors. If using `#search.searchEditor.reusePriorSearchConfiguration#`, this can be set to `null` (empty) to use the prior Search Editor's configuration.") - }, - 'search.searchEditor.focusResultsOnSearch': { - type: 'boolean', - default: false, - markdownDescription: nls.localize('search.searchEditor.focusResultsOnSearch', "When a search is triggered, focus the Search Editor results instead of the Search Editor input.") - }, 'search.sortOrder': { type: 'string', enum: [SearchSortOrder.Default, SearchSortOrder.FileNames, SearchSortOrder.Type, SearchSortOrder.Modified, SearchSortOrder.CountDescending, SearchSortOrder.CountAscending], diff --git a/src/vs/workbench/contrib/searchEditor/browser/searchEditor.contribution.ts b/src/vs/workbench/contrib/searchEditor/browser/searchEditor.contribution.ts index 39bd4e4abb40cd..60f58b7624eede 100644 --- a/src/vs/workbench/contrib/searchEditor/browser/searchEditor.contribution.ts +++ b/src/vs/workbench/contrib/searchEditor/browser/searchEditor.contribution.ts @@ -37,6 +37,8 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { IWorkingCopyIdentifier } from '../../../services/workingCopy/common/workingCopy.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { getActiveElement } from '../../../../base/browser/dom.js'; +import * as nls from '../../../../nls.js'; +import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; const OpenInEditorCommandId = 'search.action.openInEditor'; @@ -56,6 +58,52 @@ const CleanSearchEditorStateCommandId = 'cleanSearchEditorState'; const SelectAllSearchEditorMatchesCommandId = 'selectAllSearchEditorMatches'; +//#region Search Editor Configuration +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + id: 'search', + order: 13, + title: nls.localize('searchConfigurationTitle', "Search"), + type: 'object', + properties: { + 'search.searchEditor.doubleClickBehaviour': { + type: 'string', + enum: ['selectWord', 'goToLocation', 'openLocationToSide'], + default: 'goToLocation', + enumDescriptions: [ + nls.localize('search.searchEditor.doubleClickBehaviour.selectWord', "Double-clicking selects the word under the cursor."), + nls.localize('search.searchEditor.doubleClickBehaviour.goToLocation', "Double-clicking opens the result in the active editor group."), + nls.localize('search.searchEditor.doubleClickBehaviour.openLocationToSide', "Double-clicking opens the result in the editor group to the side, creating one if it does not yet exist."), + ], + markdownDescription: nls.localize('search.searchEditor.doubleClickBehaviour', "Configure effect of double-clicking a result in a search editor.") + }, + 'search.searchEditor.singleClickBehaviour': { + type: 'string', + enum: ['default', 'peekDefinition'], + default: 'default', + enumDescriptions: [ + nls.localize('search.searchEditor.singleClickBehaviour.default', "Single-clicking does nothing."), + nls.localize('search.searchEditor.singleClickBehaviour.peekDefinition', "Single-clicking opens a Peek Definition window."), + ], + markdownDescription: nls.localize('search.searchEditor.singleClickBehaviour', "Configure effect of single-clicking a result in a search editor.") + }, + 'search.searchEditor.reusePriorSearchConfiguration': { + type: 'boolean', + default: false, + markdownDescription: nls.localize({ key: 'search.searchEditor.reusePriorSearchConfiguration', comment: ['"Search Editor" is a type of editor that can display search results. "includes, excludes, and flags" refers to the "files to include" and "files to exclude" input boxes, and the flags that control whether a query is case-sensitive or a regex.'] }, "When enabled, new Search Editors will reuse the includes, excludes, and flags of the previously opened Search Editor.") + }, + 'search.searchEditor.defaultNumberOfContextLines': { + type: ['number', 'null'], + default: 1, + markdownDescription: nls.localize('search.searchEditor.defaultNumberOfContextLines', "The default number of surrounding context lines to use when creating new Search Editors. If using `#search.searchEditor.reusePriorSearchConfiguration#`, this can be set to `null` (empty) to use the prior Search Editor's configuration.") + }, + 'search.searchEditor.focusResultsOnSearch': { + type: 'boolean', + default: false, + markdownDescription: nls.localize('search.searchEditor.focusResultsOnSearch', "When a search is triggered, focus the Search Editor results instead of the Search Editor input.") + }, + } +}); +//#endregion //#region Editor Descriptior Registry.as(EditorExtensions.EditorPane).registerEditorPane( diff --git a/src/vs/workbench/contrib/tasks/browser/task.contribution.ts b/src/vs/workbench/contrib/tasks/browser/task.contribution.ts index 4fa7237b82f4ea..1ad6a06d008e31 100644 --- a/src/vs/workbench/contrib/tasks/browser/task.contribution.ts +++ b/src/vs/workbench/contrib/tasks/browser/task.contribution.ts @@ -569,7 +569,8 @@ configurationRegistry.registerConfiguration({ type: 'integer', markdownDescription: nls.localize('task.NotifyWindowOnTaskCompletion', 'Controls the minimum task runtime in milliseconds before showing an OS notification when the task finishes while the window is not in focus. Set to -1 to disable notifications. Set to 0 to always show notifications. This includes a window badge as well as notification toast.'), default: 60000, - minimum: -1 + minimum: -1, + agentsWindow: { default: -1 }, }, [TaskSettingId.VerboseLogging]: { type: 'boolean', diff --git a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts index 6b3c8ed9c57690..4e84a849b0de8c 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts @@ -11,7 +11,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { IAgentConnection, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../../../../../platform/agentHost/common/agentService.js'; import { ActionType, StateAction } from '../../../../../platform/agentHost/common/state/protocol/actions.js'; import { RootState, TerminalClaimKind, type TerminalState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; -import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; +import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; import type { ActionEnvelope, IRootConfigChangedAction, SessionAction, TerminalAction, INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult } from '../../../../../platform/agentHost/common/state/sessionProtocol.js'; @@ -72,6 +72,7 @@ class MockAgentConnection implements IAgentConnection { async createSession(_config?: IAgentCreateSessionConfig): Promise { return URI.parse('copilot:///test'); } async resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise { return { schema: { type: 'object', properties: {} }, values: {} }; } async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise { return { items: [] }; } + async completions(_params: CompletionsParams): Promise { return { items: [] }; } async disposeSession(_session: URI): Promise { } async shutdown(): Promise { } async resourceList(_uri: URI): Promise { return { entries: [] }; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index 1907a58425b6f8..7d1842393dc01e 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -88,7 +88,8 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary = { + resetDate: entitlementsData.quota_reset_date_utc ?? entitlementsData.quota_reset_date ?? entitlementsData.limited_user_reset_date, + resetDateHasTime: typeof entitlementsData.quota_reset_date_utc === 'string', + }; + + // Legacy Free SKU Quota + if (entitlementsData.monthly_quotas?.chat && typeof entitlementsData.limited_user_quotas?.chat === 'number') { + quotas.chat = { + percentRemaining: Math.min(100, Math.max(0, (entitlementsData.limited_user_quotas.chat / entitlementsData.monthly_quotas.chat) * 100)), + unlimited: false + }; + } + + if (entitlementsData.monthly_quotas?.completions && typeof entitlementsData.limited_user_quotas?.completions === 'number') { + quotas.completions = { + percentRemaining: Math.min(100, Math.max(0, (entitlementsData.limited_user_quotas.completions / entitlementsData.monthly_quotas.completions) * 100)), + unlimited: false + }; + } + + // New Quota Snapshot + if (entitlementsData.quota_snapshots) { + for (const quotaType of ['chat', 'completions', 'premium_interactions'] as const) { + const rawQuotaSnapshot = entitlementsData.quota_snapshots[quotaType]; + if (!rawQuotaSnapshot) { + continue; + } + const parsedEntitlement = rawQuotaSnapshot.entitlement !== undefined ? Number(rawQuotaSnapshot.entitlement) : undefined; + const quotaSnapshot: IQuotaSnapshot = { + percentRemaining: Math.min(100, Math.max(0, rawQuotaSnapshot.percent_remaining)), + unlimited: rawQuotaSnapshot.unlimited, + usageBasedBilling: entitlementsData.token_based_billing, + resetAt: rawQuotaSnapshot.quota_reset_at || undefined, + entitlement: parsedEntitlement !== undefined && Number.isSafeInteger(parsedEntitlement) && parsedEntitlement >= 0 ? parsedEntitlement : undefined, + }; + + switch (quotaType) { + case 'chat': + quotas.chat = quotaSnapshot; + break; + case 'completions': + quotas.completions = quotaSnapshot; + break; + case 'premium_interactions': + quotas.premiumChat = quotaSnapshot; + break; + } + } + + const overageSource = entitlementsData.quota_snapshots['premium_interactions']; + quotas.additionalUsageEnabled = overageSource?.overage_permitted ?? false; + quotas.additionalUsageCount = overageSource?.overage_count ?? 0; + } + return quotas; +} + export class ChatEntitlementRequests extends Disposable { private state: IEntitlements; @@ -821,60 +878,7 @@ export class ChatEntitlementRequests extends Disposable { } private toQuotas(entitlementsData: IEntitlementsData): IQuotas { - const quotas: Mutable = { - resetDate: entitlementsData.quota_reset_date_utc ?? entitlementsData.quota_reset_date ?? entitlementsData.limited_user_reset_date, - resetDateHasTime: typeof entitlementsData.quota_reset_date_utc === 'string', - }; - - // Legacy Free SKU Quota - if (entitlementsData.monthly_quotas?.chat && typeof entitlementsData.limited_user_quotas?.chat === 'number') { - quotas.chat = { - percentRemaining: Math.min(100, Math.max(0, (entitlementsData.limited_user_quotas.chat / entitlementsData.monthly_quotas.chat) * 100)), - unlimited: false - }; - } - - if (entitlementsData.monthly_quotas?.completions && typeof entitlementsData.limited_user_quotas?.completions === 'number') { - quotas.completions = { - percentRemaining: Math.min(100, Math.max(0, (entitlementsData.limited_user_quotas.completions / entitlementsData.monthly_quotas.completions) * 100)), - unlimited: false - }; - } - - // New Quota Snapshot - if (entitlementsData.quota_snapshots) { - for (const quotaType of ['chat', 'completions', 'premium_interactions'] as const) { - const rawQuotaSnapshot = entitlementsData.quota_snapshots[quotaType]; - if (!rawQuotaSnapshot) { - continue; - } - const parsedEntitlement = rawQuotaSnapshot.entitlement !== undefined ? Number(rawQuotaSnapshot.entitlement) : undefined; - const quotaSnapshot: IQuotaSnapshot = { - percentRemaining: Math.min(100, Math.max(0, rawQuotaSnapshot.percent_remaining)), - unlimited: rawQuotaSnapshot.unlimited, - usageBasedBilling: rawQuotaSnapshot.token_based_billing, - resetAt: rawQuotaSnapshot.quota_reset_at || undefined, - entitlement: parsedEntitlement !== undefined && Number.isSafeInteger(parsedEntitlement) && parsedEntitlement >= 0 ? parsedEntitlement : undefined, - }; - - switch (quotaType) { - case 'chat': - quotas.chat = quotaSnapshot; - break; - case 'completions': - quotas.completions = quotaSnapshot; - break; - case 'premium_interactions': - quotas.premiumChat = quotaSnapshot; - break; - } - } - - const overageSource = entitlementsData.quota_snapshots['premium_interactions']; - quotas.additionalUsageEnabled = overageSource?.overage_permitted ?? false; - quotas.additionalUsageCount = overageSource?.overage_count ?? 0; - } - return quotas; + return parseQuotas(entitlementsData); } private async request(url: string, type: 'GET', body: undefined, sessions: AuthenticationSession[], token: CancellationToken, callSite: string): Promise; diff --git a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts index 61c5679167126b..f1c685b03794f9 100644 --- a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts @@ -9,7 +9,7 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { Event } from '../../../base/common/event.js'; import { DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { Schemas } from '../../../base/common/network.js'; -import { URI } from '../../../base/common/uri.js'; +import { URI, UriComponents } from '../../../base/common/uri.js'; import { IModelService } from '../../../editor/common/services/model.js'; import { ModelService } from '../../../editor/common/services/modelService.js'; import { TestConfigurationService } from '../../../platform/configuration/test/common/testConfigurationService.js'; @@ -103,7 +103,7 @@ export class TestNativeHostService implements INativeHostService { throw new Error('Method not implemented.'); } - async openAgentsWindow(): Promise { } + async openAgentsWindow(_options?: { folderUri?: UriComponents }): Promise { } async launchSiblingApp(_args?: string[]): Promise { } diff --git a/src/vscode-dts/vscode.proposed.agentsWindowConfiguration.d.ts b/src/vscode-dts/vscode.proposed.agentsWindowConfiguration.d.ts new file mode 100644 index 00000000000000..47fe592ab16847 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.agentsWindowConfiguration.d.ts @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Empty module declaration — this proposed API enables the `agentsWindow` +// property in `contributes.configuration` property schemas (package.json). +// No TypeScript API surface is added.