diff --git a/.github/workflows/screenshot-test.yml b/.github/workflows/screenshot-test.yml
index 6354e498a4dfa..937f95e700589 100644
--- a/.github/workflows/screenshot-test.yml
+++ b/.github/workflows/screenshot-test.yml
@@ -138,17 +138,23 @@ jobs:
- name: Diff screenshots against merge base
id: diff
- if: github.event_name == 'pull_request' && steps.oidc.outputs.token
+ if: steps.oidc.outputs.token
run: |
- # We diff screenshots(checked-out commit) vs screenshots(merge-base of
- # that commit with the target branch). This isolates the visual effect
- # of just this PR's divergence from target. Using pull_request.base.sha
- # would be wrong: it's the target-branch tip at PR creation time and can
- # be stale, causing unrelated target-branch commits to show up as diffs.
- TARGET_REF="origin/${{ github.event.pull_request.base.ref }}"
- git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.ref }}"
- BASE_SHA=$(git merge-base "${{ github.sha }}" "$TARGET_REF")
- echo "Using base SHA: $BASE_SHA (merge-base of ${{ github.sha }} and $TARGET_REF)"
+ if [ "${{ github.event_name }}" = "pull_request" ]; then
+ # For PRs, diff against the merge-base with the target branch.
+ # This isolates the visual effect of just this PR's divergence
+ # from target. Using pull_request.base.sha would be wrong: it's
+ # the target-branch tip at PR creation time and can be stale,
+ # causing unrelated target-branch commits to show up as diffs.
+ TARGET_REF="origin/${{ github.event.pull_request.base.ref }}"
+ git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.ref }}"
+ BASE_SHA=$(git merge-base "${{ github.sha }}" "$TARGET_REF")
+ else
+ # For push events, diff against the parent commit.
+ BASE_SHA=$(git rev-parse "${{ github.sha }}^")
+ fi
+ echo "base_sha=$BASE_SHA" >> "$GITHUB_OUTPUT"
+ echo "Using base SHA: $BASE_SHA (base for ${{ github.sha }})"
BODY=$(node build/lib/screenshotDiffReport.ts \
https://hediet-screenshots.azurewebsites.net \
${{ github.repository_owner }} \
@@ -172,8 +178,25 @@ jobs:
env:
SCREENSHOT_SERVICE_TOKEN: ${{ steps.oidc.outputs.token }}
+ - name: Write job summary
+ if: steps.diff.outputs.has_changes == 'true' || steps.blocks-ci.outputs.match == 'false'
+ run: |
+ BODY="${COMMENT_BODY}"
+ if [ -n "$BLOCKS_CI_CONTENT" ]; then
+ if [ -n "$BODY" ]; then BODY+=$'\n\n---\n\n'; fi
+ BODY+="### blocks-ci screenshots changed"$'\n\n'
+ BODY+="Replace the contents of \`test/componentFixtures/blocks-ci-screenshots.md\` with:"$'\n\n'
+ BODY+=""$'\n'"Updated blocks-ci-screenshots.md
"$'\n\n'
+ BODY+="\`\`\`md"$'\n'"${BLOCKS_CI_CONTENT}"$'\n'"\`\`\`"$'\n\n'
+ BODY+=" "
+ fi
+ echo "$BODY" >> "$GITHUB_STEP_SUMMARY"
+ env:
+ COMMENT_BODY: ${{ steps.diff.outputs.body }}
+ BLOCKS_CI_CONTENT: ${{ steps.blocks-ci.outputs.content }}
+
- name: Post PR comment
- if: github.event_name == 'pull_request' && (steps.diff.outputs.has_changes == 'true' || steps.blocks-ci.outputs.match == 'false')
+ if: github.event_name == 'pull_request'
uses: actions/github-script@v9
with:
script: |
@@ -190,7 +213,7 @@ jobs:
body += '';
}
- body = marker + '\n' + body;
+ const hasContent = body || blocksCiContent;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
@@ -200,6 +223,27 @@ jobs:
});
const existing = comments.find(c => c.body?.startsWith(marker));
+ if (!hasContent) {
+ // No changes to report — update existing comment if present, otherwise do nothing
+ if (existing) {
+ const baseSha = (process.env.BASE_SHA || '').slice(0, 8);
+ const currentSha = (process.env.CURRENT_SHA || '').slice(0, 8);
+ let noChangesBody = '~No screenshot changes.~';
+ if (baseSha && currentSha) {
+ noChangesBody = `**Base:** \`${baseSha}\` **Current:** \`${currentSha}\`\n\n` + noChangesBody;
+ }
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: existing.id,
+ body: marker + '\n' + noChangesBody,
+ });
+ }
+ return;
+ }
+
+ body = marker + '\n' + body;
+
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
@@ -218,6 +262,8 @@ jobs:
env:
COMMENT_BODY: ${{ steps.diff.outputs.body }}
BLOCKS_CI_CONTENT: ${{ steps.blocks-ci.outputs.content }}
+ BASE_SHA: ${{ steps.diff.outputs.base_sha }}
+ CURRENT_SHA: ${{ github.sha }}
- name: Fail if blocks-ci hashes changed
if: steps.blocks-ci.outputs.match == 'false'
diff --git a/cli/Cargo.lock b/cli/Cargo.lock
index 93862f3137794..5e8a2c7a25eab 100644
--- a/cli/Cargo.lock
+++ b/cli/Cargo.lock
@@ -1894,9 +1894,9 @@ dependencies = [
[[package]]
name = "openssl"
-version = "0.10.75"
+version = "0.10.78"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
+checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
@@ -1926,9 +1926,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
-version = "0.9.111"
+version = "0.9.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
+checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6"
dependencies = [
"cc",
"libc",
diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json
index 6a7d00d955ac0..5fbdd80f6a69d 100644
--- a/extensions/copilot/package.json
+++ b/extensions/copilot/package.json
@@ -4018,6 +4018,15 @@
"tags": [
"experimental"
]
+ },
+ "github.copilot.chat.localIndex.enabled": {
+ "type": "boolean",
+ "default": false,
+ "markdownDescription": "%github.copilot.config.localIndex.enabled%",
+ "tags": [
+ "experimental",
+ "onExp"
+ ]
}
}
},
diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json
index 26cd798a0a55b..6d6f23d955764 100644
--- a/extensions/copilot/package.nls.json
+++ b/extensions/copilot/package.nls.json
@@ -169,10 +169,11 @@
"copilot.agent.description": "Edit files in your workspace in agent mode",
"copilot.agent.compact.description": "Free up context by compacting the conversation history. Optionally include extra instructions for compaction.",
"copilot.chronicle.description": "Session history tools and insights",
- "copilot.chronicle.standup.description": "Generate a standup report from recent coding sessions",
- "copilot.chronicle.tips.description": "Get personalized tips based on your Copilot usage patterns",
+ "copilot.chronicle.standup.description": "Generate a standup report from recent chat sessions",
+ "copilot.chronicle.tips.description": "Get personalized tips based on your chat session usage patterns",
"github.copilot.config.sessionSearch.enabled": "Enable session search and /chronicle commands. This is a team-internal setting.",
"github.copilot.config.sessionSearch.localIndex.enabled": "Enable local session tracking. When enabled, Copilot tracks session data locally for /chronicle commands.",
+ "github.copilot.config.localIndex.enabled": "Enable local session tracking. When enabled, session data is tracked locally for /chronicle commands.",
"github.copilot.config.sessionSearch.cloudSync.enabled": "Enable cloud sync for session data. When enabled, session data is synced to your Copilot account for cross-device access.",
"github.copilot.config.sessionSearch.cloudSync.excludeRepositories": "Repository patterns to exclude from cloud sync. Use exact `owner/repo` names or glob patterns like `my-org/*`. Sessions from matching repos will only be stored locally.",
"copilot.workspace.explain.description": "Explain how the code in your active editor works",
diff --git a/extensions/copilot/src/extension/agents/vscode-node/planAgentProvider.ts b/extensions/copilot/src/extension/agents/vscode-node/planAgentProvider.ts
index 4ef9f2fb018b8..8bb8006c81bee 100644
--- a/extensions/copilot/src/extension/agents/vscode-node/planAgentProvider.ts
+++ b/extensions/copilot/src/extension/agents/vscode-node/planAgentProvider.ts
@@ -9,6 +9,7 @@ import { AGENT_FILE_EXTENSION } from '../../../platform/customInstructions/commo
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';
import { ILogService } from '../../../platform/log/common/logService';
+import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService';
import { Disposable } from '../../../util/vs/base/common/lifecycle';
import { AgentConfig, AgentHandoff, buildAgentMarkdown, DEFAULT_READ_TOOLS } from './agentTypes';
@@ -22,10 +23,8 @@ const BASE_PLAN_AGENT_CONFIG: AgentConfig = {
argumentHint: 'Outline the goal or problem to research',
target: 'vscode',
disableModelInvocation: true,
- agents: ['Explore'],
tools: [
...DEFAULT_READ_TOOLS,
- 'agent',
],
handoffs: [], // Handoffs are generated dynamically in buildCustomizedConfig
body: '' // Body is generated dynamically in buildCustomizedConfig
@@ -52,6 +51,7 @@ export class PlanAgentProvider extends Disposable implements vscode.ChatCustomAg
@IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext,
@IFileSystemService private readonly fileSystemService: IFileSystemService,
@ILogService private readonly logService: ILogService,
+ @IExperimentationService private readonly experimentationService: IExperimentationService,
) {
super();
@@ -63,7 +63,9 @@ export class PlanAgentProvider extends Disposable implements vscode.ChatCustomAg
if (e.affectsConfiguration(ConfigKey.PlanAgentAdditionalTools.fullyQualifiedId) ||
e.affectsConfiguration(ConfigKey.Deprecated.PlanAgentModel.fullyQualifiedId) ||
e.affectsConfiguration('chat.planAgent.defaultModel') ||
- e.affectsConfiguration(ConfigKey.ImplementAgentModel.fullyQualifiedId)) {
+ e.affectsConfiguration(ConfigKey.ImplementAgentModel.fullyQualifiedId) ||
+ e.affectsConfiguration(ConfigKey.ExploreAgentEnabled.fullyQualifiedId) ||
+ e.affectsConfiguration(ConfigKey.Advanced.SearchSubagentToolEnabled.fullyQualifiedId)) {
this._onDidChangeCustomAgents.fire();
}
}));
@@ -103,12 +105,27 @@ export class PlanAgentProvider extends Disposable implements vscode.ChatCustomAg
return fileUri;
}
- static buildAgentBody(): string {
- const discoverySection = `## 1. Discovery
+ static buildAgentBody(exploreEnabled: boolean, searchSubagentEnabled: boolean): string {
+ let discoverySection: string;
+ if (exploreEnabled) {
+ discoverySection = `## 1. Discovery
Run the *Explore* subagent to gather context, analogous existing features to use as implementation templates, and potential blockers or ambiguities. When the task spans multiple independent areas (e.g., frontend + backend, different features, separate repos), launch **2-3 *Explore* subagents in parallel** — one per area — to speed up discovery.
Update the plan with your findings.`;
+ } else if (searchSubagentEnabled) {
+ discoverySection = `## 1. Discovery
+
+Use #tool:searchSubagent to gather context, analogous existing features to use as implementation templates, and potential blockers or ambiguities. When the task spans multiple independent areas (e.g., frontend + backend, different features, separate repos), launch **2-3 search subagents in parallel** — one per area — to speed up discovery.
+
+Update the plan with your findings.`;
+ } else {
+ discoverySection = `## 1. Discovery
+
+Search the codebase to gather context, analogous existing features to use as implementation templates, and potential blockers or ambiguities.
+
+Update the plan with your findings.`;
+ }
return `You are a PLANNING AGENT, pairing with the user to create a detailed, actionable plan.
@@ -197,6 +214,8 @@ Rules:
private buildCustomizedConfig(): AgentConfig {
const additionalTools = this.configurationService.getConfig(ConfigKey.PlanAgentAdditionalTools);
+ const isExploreEnabled = this.configurationService.getExperimentBasedConfig(ConfigKey.ExploreAgentEnabled, this.experimentationService);
+ const isSearchSubagentEnabled = this.configurationService.getExperimentBasedConfig(ConfigKey.Advanced.SearchSubagentToolEnabled, this.experimentationService);
const coreDefaultModel = this.configurationService.getNonExtensionConfig('chat.planAgent.defaultModel');
const modelOverride = coreDefaultModel || this.configurationService.getConfig(ConfigKey.Deprecated.PlanAgentModel);
@@ -225,6 +244,11 @@ Rules:
// Always include askQuestions tool (now provided by core)
toolsToAdd.push('vscode/askQuestions');
+ // When explore agent is enabled, include the 'agent' tool to allow sub-agent calls
+ if (isExploreEnabled) {
+ toolsToAdd.push('agent');
+ }
+
// Merge additional tools (deduplicated)
const tools = toolsToAdd.length > 0
? [...new Set([...BASE_PLAN_AGENT_CONFIG.tools, ...toolsToAdd])]
@@ -233,9 +257,11 @@ Rules:
// Start with base config
return {
...BASE_PLAN_AGENT_CONFIG,
+ // When explore agent is enabled, allow the Explore subagent
+ ...(isExploreEnabled ? { agents: ['Explore'] } : {}),
tools,
handoffs: [startImplementationHandoff, openInEditorHandoff, ...(BASE_PLAN_AGENT_CONFIG.handoffs ?? [])],
- body: PlanAgentProvider.buildAgentBody(),
+ body: PlanAgentProvider.buildAgentBody(isExploreEnabled, isSearchSubagentEnabled),
...(modelOverride ? { model: modelOverride } : {}),
};
}
diff --git a/extensions/copilot/src/extension/agents/vscode-node/test/planAgentProvider.spec.ts b/extensions/copilot/src/extension/agents/vscode-node/test/planAgentProvider.spec.ts
index 1eaa729888619..278190d0fcf90 100644
--- a/extensions/copilot/src/extension/agents/vscode-node/test/planAgentProvider.spec.ts
+++ b/extensions/copilot/src/extension/agents/vscode-node/test/planAgentProvider.spec.ts
@@ -362,6 +362,70 @@ suite('PlanAgentProvider', () => {
assert.equal(eventFired, true);
});
+
+ test('fires onDidChangeCustomAgents when SearchSubagentToolEnabled setting changes', async () => {
+ const provider = createProvider();
+
+ let eventFired = false;
+ provider.onDidChangeCustomAgents(() => {
+ eventFired = true;
+ });
+
+ await mockConfigurationService.setConfig(ConfigKey.Advanced.SearchSubagentToolEnabled, true);
+
+ assert.equal(eventFired, true);
+ });
+
+ test('buildAgentBody uses Explore discovery when explore is enabled', () => {
+ const body = PlanAgentProvider.buildAgentBody(true, true);
+ assert.ok(body.includes('Run the *Explore* subagent'));
+ assert.ok(!body.includes('#tool:searchSubagent'));
+ });
+
+ test('buildAgentBody uses search subagent discovery when explore is disabled but search is enabled', () => {
+ const body = PlanAgentProvider.buildAgentBody(false, true);
+ assert.ok(body.includes('#tool:searchSubagent'));
+ assert.ok(!body.includes('Run the *Explore* subagent'));
+ });
+
+ test('buildAgentBody uses generic discovery when both explore and search are disabled', () => {
+ const body = PlanAgentProvider.buildAgentBody(false, false);
+ assert.ok(body.includes('Search the codebase to gather context'));
+ assert.ok(!body.includes('Run the *Explore* subagent'));
+ assert.ok(!body.includes('#tool:searchSubagent'));
+ });
+
+ test('excludes agent tool and Explore subagent when explore is disabled', async () => {
+ await mockConfigurationService.setConfig(ConfigKey.ExploreAgentEnabled, false);
+
+ const provider = createProvider();
+ const agents = await provider.provideCustomAgents({}, {} as any);
+ const content = await getAgentContent(agents[0]);
+
+ // Should not have the 'agent' tool
+ const toolsMatch = content.match(/tools: \[([^\]]+)\]/);
+ assert.ok(toolsMatch);
+ assert.ok(!toolsMatch[1].includes('\'agent\''), 'Should not include agent tool when explore is disabled');
+
+ // Should not have agents field
+ assert.ok(!content.includes('agents:'), 'Should not include agents field when explore is disabled');
+ });
+
+ test('includes agent tool and Explore subagent when explore is enabled', async () => {
+ await mockConfigurationService.setConfig(ConfigKey.ExploreAgentEnabled, true);
+
+ const provider = createProvider();
+ const agents = await provider.provideCustomAgents({}, {} as any);
+ const content = await getAgentContent(agents[0]);
+
+ // Should have the 'agent' tool
+ const toolsMatch = content.match(/tools: \[([^\]]+)\]/);
+ assert.ok(toolsMatch);
+ assert.ok(toolsMatch[1].includes('\'agent\''), 'Should include agent tool when explore is enabled');
+
+ // Should have agents field with Explore
+ assert.ok(content.includes('agents:'), 'Should include agents field when explore is enabled');
+ });
});
suite('buildAgentMarkdown', () => {
diff --git a/extensions/copilot/src/extension/byok/common/byokProvider.ts b/extensions/copilot/src/extension/byok/common/byokProvider.ts
index 54237bd90e507..7bfe1c0722894 100644
--- a/extensions/copilot/src/extension/byok/common/byokProvider.ts
+++ b/extensions/copilot/src/extension/byok/common/byokProvider.ts
@@ -143,7 +143,11 @@ export function byokKnownModelToAPIInfo(providerName: string, id: string, capabi
version: '1.0.0',
maxOutputTokens: capabilities.maxOutputTokens,
maxInputTokens: capabilities.maxInputTokens,
- detail: providerName,
+ // `detail` is intentionally omitted: when this model is resolved
+ // via a configured provider group, `LanguageModelsService` will
+ // fall back to the group name so multiple instances of the same
+ // vendor (e.g. multiple Ollama servers) are distinguishable in
+ // the model picker.
family: id,
tooltip: `${capabilities.name} is contributed via the ${providerName} provider.`,
multiplierNumeric: 0,
diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/common/pendingRequestContext.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/common/pendingRequestContext.ts
new file mode 100644
index 0000000000000..fb5479937206d
--- /dev/null
+++ b/extensions/copilot/src/extension/chatSessions/copilotcli/common/pendingRequestContext.ts
@@ -0,0 +1,30 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import type { Attachment, SendOptions } from '@github/copilot/sdk';
+
+export interface ICopilotCLIPendingRequestContext {
+ readonly prompt: string;
+ readonly attachments: Attachment[];
+ readonly source?: SendOptions['source'];
+}
+
+const pendingRequestContextBySessionId = new Map();
+
+export function setPendingCopilotCLIRequestContext(sessionId: string, context: ICopilotCLIPendingRequestContext): void {
+ pendingRequestContextBySessionId.set(sessionId, context);
+}
+
+export function takePendingCopilotCLIRequestContext(sessionId: string): ICopilotCLIPendingRequestContext | undefined {
+ const context = pendingRequestContextBySessionId.get(sessionId);
+ if (context) {
+ pendingRequestContextBySessionId.delete(sessionId);
+ }
+ return context;
+}
+
+export function clearPendingCopilotCLIRequestContext(sessionId: string): void {
+ pendingRequestContextBySessionId.delete(sessionId);
+}
diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts
index 51ec6f29bcdbe..b7decb3b93fed 100644
--- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts
+++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts
@@ -34,6 +34,7 @@ import { IChatSessionMetadataStore } from '../../common/chatSessionMetadataStore
import { ExternalEditTracker } from '../../common/externalEditTracker';
import { getWorkingDirectory, isIsolationEnabled, IWorkspaceInfo } from '../../common/workspaceInfo';
import { enrichToolInvocationWithSubagentMetadata, isCopilotCliEditToolCall, isCopilotCLIToolThatCouldRequirePermissions, isTodoRelatedSqlQuery, processToolExecutionComplete, processToolExecutionStart, ToolCall, updateTodoListFromSqlItems, clearTodoList } from '../common/copilotCLITools';
+import { clearPendingCopilotCLIRequestContext, setPendingCopilotCLIRequestContext } from '../common/pendingRequestContext';
import { getCopilotCLISessionDir } from './cliHelpers';
import { SessionIdForCLI } from '../common/utils';
import type { CopilotCliBridgeSpanProcessor } from './copilotCliBridgeSpanProcessor';
@@ -68,6 +69,9 @@ interface McSharedState {
mcFlushInterval: ReturnType | undefined;
mcPollInterval: ReturnType | undefined;
mcLastEventId: string | null;
+ mcLastSubmitAttemptTimeMs: number;
+ mcProcessedCommandIds: Set;
+ mcPendingCommandCompletionIds?: Set;
/** Reference to the SDK session for steering from the command poller. */
mcSdkSession: Session;
/** Dispose function for the persistent on('*') listener for MC events. */
@@ -77,6 +81,8 @@ interface McSharedState {
}
const mcStateBySessionId = new Map();
+const MISSION_CONTROL_KEEPALIVE_INTERVAL_MS = 10_000;
+
interface McPermissionResponseCommandData {
readonly promptId?: string;
readonly approved?: boolean;
@@ -90,7 +96,6 @@ const skippedMissionControlEventTypes = new Set([
'session.error',
'session.usage_info',
'assistant.usage',
- 'session.title_changed',
'pending_messages.modified',
'session.mcp_server_status_changed',
'session.mcp_servers_loaded',
@@ -116,14 +121,54 @@ function shouldForwardMissionControlEvent(event: { type?: string; data?: unknown
return true;
}
+function getMissionControlCommandIdFromEvent(event: { type?: string; data?: unknown }): string | undefined {
+ if (event.type !== 'user.message') {
+ return undefined;
+ }
+
+ const source = typeof event.data === 'object' && event.data !== null && 'source' in event.data
+ ? event.data.source
+ : undefined;
+ return typeof source === 'string' && source.startsWith('command-')
+ ? source.slice('command-'.length)
+ : undefined;
+}
+
+function getMissionControlSessionTitleFromEvent(event: { type?: string; data?: unknown }): string | undefined {
+ if (event.type !== 'session.title_changed') {
+ return undefined;
+ }
+
+ const title = typeof event.data === 'object' && event.data !== null && 'title' in event.data
+ ? event.data.title
+ : undefined;
+ return typeof title === 'string' && title.trim().length > 0 ? title : undefined;
+}
+
+function getMissionControlPendingCommandCompletionIds(state: McSharedState): Set {
+ state.mcPendingCommandCompletionIds ??= new Set();
+ return state.mcPendingCommandCompletionIds;
+}
+
+function maybeAcknowledgeMissionControlCommandFromEvent(state: McSharedState, event: { type?: string; data?: unknown }): void {
+ const commandId = getMissionControlCommandIdFromEvent(event);
+ if (!commandId) {
+ return;
+ }
+
+ if (getMissionControlPendingCommandCompletionIds(state).delete(commandId)) {
+ state.mcCompletedCommandIds.push(commandId);
+ }
+}
+
export { builtinSlashCommands as builtinSlashSCommands } from '../../common/builtinSlashCommands';
/**
* Either a free-form prompt **or** a known command.
*/
export type CopilotCLISessionInput =
- | { readonly prompt: string }
- | { readonly prompt?: string; readonly command: CopilotCLICommand };
+ | { readonly prompt: string; readonly source?: SendOptions['source'] }
+ | { readonly prompt?: string; readonly command: CopilotCLICommand; readonly source?: SendOptions['source'] };
function getPromptLabel(input: CopilotCLISessionInput): string {
if ('command' in input) {
@@ -778,7 +823,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
disposables.add(toDisposable(this._sdkSession.on('session.error', (event) => {
flushPendingInvocationMessages();
this.logService.error(`[CopilotCLISession]CopilotCLI error: (${event.data.errorType}), ${event.data.message}`);
- this._stream?.markdown(`\n\nError: (${event.data.errorType}) ${event.data.message}`);
+ this._stream?.markdown(l10n.t('\n\nError: ({0}) {1}', event.data.errorType, event.data.message));
const errorMarkdown = [`# Error Details`, `Type: ${event.data.errorType}`, `Message: ${event.data.message}`, `## Stack`, event.data.stack || ''].join('\n');
this._requestLogger.addEntry({
@@ -866,7 +911,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
this._status = ChatSessionStatus.Failed;
this._statusChange.fire(this._status);
this.logService.error(`[CopilotCLISession] Invoking session (error) ${this.sessionId}`, error);
- this._stream?.markdown(`\n\nError: ${error instanceof Error ? error.message : String(error)}`);
+ this._stream?.markdown(l10n.t('\n\nError: {0}', error instanceof Error ? error.message : String(error)));
invokeAgentSpan.setStatus(SpanStatusCode.ERROR, error instanceof Error ? error.message : String(error));
if (error instanceof Error) {
@@ -982,6 +1027,9 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
if (steering) {
sendOptions.mode = 'immediate';
}
+ if (input.source) {
+ sendOptions.source = input.source;
+ }
await this._sdkSession.send(sendOptions);
}
}
@@ -1092,6 +1140,9 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
mcFlushInterval: undefined,
mcPollInterval: undefined,
mcLastEventId: null,
+ mcLastSubmitAttemptTimeMs: Date.now(),
+ mcProcessedCommandIds: new Set(),
+ mcPendingCommandCompletionIds: new Set(),
mcSdkSession: this._sdkSession,
mcEventListenerDispose: undefined,
mcSessionResource: SessionIdForCLI.getResource(this.sessionId),
@@ -1122,6 +1173,13 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
remoteSteerable: true,
}));
+ const sessionTitle = await this._getMissionControlSessionTitle();
+ if (sessionTitle) {
+ sharedState.mcEventBuffer.push(this._createMcEvent('session.title_changed', {
+ title: sessionTitle,
+ }, true));
+ }
+
// Step 7b: Replay existing conversation history so the MC web UI
// shows all messages that occurred before /remote was invoked.
// Only replay conversation-content events — skip session lifecycle
@@ -1155,15 +1213,21 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
// Use the static helper instead of this._bufferMcEvent to avoid
// relying on the instance that started MC (it may be stale).
const eventType = (event as { type?: string }).type ?? 'unknown';
- if (!shouldForwardMissionControlEvent(event as { type?: string; data?: unknown })) {
+ const e = event as { type?: string; data?: unknown; id?: string; timestamp?: string; parentId?: string | null; ephemeral?: boolean };
+ if (!shouldForwardMissionControlEvent(e)) {
return;
}
- const e = event as { type?: string; data?: unknown; id?: string; timestamp?: string; parentId?: string | null };
+ const updatedTitle = getMissionControlSessionTitleFromEvent(e);
+ if (updatedTitle) {
+ this._title = updatedTitle;
+ }
+ maybeAcknowledgeMissionControlCommandFromEvent(state, e);
if (e.id && e.timestamp) {
state.mcEventBuffer.push({
id: e.id,
timestamp: e.timestamp,
parentId: e.parentId ?? state.mcLastEventId ?? null,
+ ephemeral: e.ephemeral,
type: eventType,
data: (e.data ?? {}) as Record,
});
@@ -1211,12 +1275,13 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
}
/**
- * Tear down an active Mission Control session.
+ * Disable remote control for an active Mission Control session.
*/
private async _teardownRemoteControl(): Promise {
- // Stop exporter and poller
- this._stopMcEventExporter();
+ // Stop local scheduling first so no more commands or periodic flushes race
+ // with the final disabled-state transition we send to Mission Control.
this._stopMcCommandPoller();
+ this._stopMcEventExporter(false);
const state = this._mcState;
if (!state) {
@@ -1234,11 +1299,14 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
}
state.mcPendingPermissionRequests.clear();
- const mcSessionId = state.mcSessionId;
- mcStateBySessionId.delete(this.sessionId);
- this.logService.info(`[CopilotCLISession] Tearing down MC session ${mcSessionId}`);
+ state.mcEventBuffer.push(this._createMcEvent('session.remote_steerable_changed', {
+ remoteSteerable: false,
+ }));
+ state.mcEventBuffer.push(this._createMcEvent('session.idle', {}));
+ await this._flushMcEvents();
- await this._missionControlApiClient.deleteSession(mcSessionId);
+ mcStateBySessionId.delete(this.sessionId);
+ this.logService.info(`[CopilotCLISession] Disabled MC remote control for session ${state.mcSessionId}`);
}
/**
@@ -1285,13 +1353,13 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
}
/** Stop the MC event exporter. */
- private _stopMcEventExporter(): void {
+ private _stopMcEventExporter(clearBuffer = true): void {
const state = this._mcState;
if (state?.mcFlushInterval) {
clearInterval(state.mcFlushInterval);
state.mcFlushInterval = undefined;
}
- if (state) {
+ if (state && clearBuffer) {
state.mcEventBuffer.length = 0;
}
}
@@ -1300,7 +1368,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
* Buffer an SDK event for Mission Control. Called from the per-send
* on('*') handler so that events are captured on every turn.
*/
- private _bufferMcEvent(event: { type?: string; data?: unknown; id?: string; timestamp?: string; parentId?: string | null }): void {
+ private _bufferMcEvent(event: { type?: string; data?: unknown; id?: string; timestamp?: string; parentId?: string | null; ephemeral?: boolean }): void {
const state = this._mcState;
const eventType = event.type ?? 'unknown';
if (!state) {
@@ -1309,7 +1377,12 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
if (!shouldForwardMissionControlEvent(event)) {
return;
}
- this.logService.info(`[CopilotCLISession] MC buffered event: ${eventType}`);
+ const updatedTitle = getMissionControlSessionTitleFromEvent(event);
+ if (updatedTitle) {
+ this._title = updatedTitle;
+ }
+ maybeAcknowledgeMissionControlCommandFromEvent(state, event);
+ this.logService.trace(`[CopilotCLISession] MC buffered event: ${eventType}`);
// If the SDK event already has a UUID id, pass it through directly
// to preserve the event identity chain. Otherwise create a new event.
@@ -1318,6 +1391,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
id: event.id,
timestamp: event.timestamp,
parentId: event.parentId ?? state.mcLastEventId ?? null,
+ ephemeral: event.ephemeral,
type: eventType,
data: (event.data ?? {}) as Record,
};
@@ -1329,13 +1403,14 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
}
/** Create an MC event with a UUID v4 ID and parentId chain. */
- private _createMcEvent(type: string, data: Record): McEvent {
+ private _createMcEvent(type: string, data: Record, ephemeral?: boolean): McEvent {
const state = this._mcState;
const id = crypto.randomUUID();
const event: McEvent = {
id,
timestamp: new Date().toISOString(),
parentId: state?.mcLastEventId ?? null,
+ ephemeral,
type,
data,
};
@@ -1345,6 +1420,41 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
return event;
}
+ private async _getMissionControlSessionTitle(): Promise {
+ const liveTitle = this._title?.trim();
+ if (liveTitle) {
+ return liveTitle;
+ }
+
+ const sessionEvents = this._sdkSession.getEvents() as readonly { type?: string; data?: unknown }[];
+ for (let i = sessionEvents.length - 1; i >= 0; i--) {
+ const eventTitle = getMissionControlSessionTitleFromEvent(sessionEvents[i]);
+ if (eventTitle) {
+ return eventTitle;
+ }
+ }
+
+ const customTitle = (await this._chatSessionMetadataStore.getCustomTitle(this.sessionId))?.trim();
+ if (customTitle) {
+ return customTitle;
+ }
+
+ for (const event of sessionEvents) {
+ if (event.type !== 'user.message') {
+ continue;
+ }
+ const content = typeof event.data === 'object' && event.data !== null && 'content' in event.data
+ ? event.data.content
+ : undefined;
+ if (typeof content === 'string' && content.trim().length > 0) {
+ return content.trim();
+ }
+ }
+
+ const pendingTitle = this._pendingPrompt?.trim();
+ return pendingTitle || undefined;
+ }
+
private _waitForMcPermissionResponse(
state: McSharedState,
permissionRequest: PermissionRequest,
@@ -1376,15 +1486,24 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
*/
private async _flushMcEvents(): Promise {
const state = this._mcState;
- if (!state || !state.mcSessionId || state.mcEventBuffer.length === 0) {
+ if (!state || !state.mcSessionId) {
return;
}
- const events = state.mcEventBuffer.splice(0, 500);
const completedCommandIds = state.mcCompletedCommandIds.splice(0);
+ const shouldSendKeepAlive =
+ state.mcEventBuffer.length === 0 &&
+ completedCommandIds.length === 0 &&
+ Date.now() - state.mcLastSubmitAttemptTimeMs >= MISSION_CONTROL_KEEPALIVE_INTERVAL_MS;
+ if (state.mcEventBuffer.length === 0 && completedCommandIds.length === 0 && !shouldSendKeepAlive) {
+ return;
+ }
+
+ state.mcLastSubmitAttemptTimeMs = Date.now();
+ const events = state.mcEventBuffer.splice(0, 500);
const eventTypes = events.map(e => e.type).join(', ');
- this.logService.info(`[CopilotCLISession] Flushing ${events.length} MC event(s): [${eventTypes}]`);
+ this.logService.info(`[CopilotCLISession] Flushing ${events.length} MC event(s): [${eventTypes}]${completedCommandIds.length ? ` with ${completedCommandIds.length} completed command(s)` : ''}${shouldSendKeepAlive ? ' (keepalive)' : ''}`);
try {
const success = await this._missionControlApiClient.submitEvents(state.mcSessionId, events, completedCommandIds);
@@ -1393,10 +1512,12 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
if (state.mcEventBuffer.length < 2000) {
state.mcEventBuffer.unshift(...events);
}
+ state.mcCompletedCommandIds.unshift(...completedCommandIds);
} else {
this.logService.info(`[CopilotCLISession] MC event flush OK: ${events.length} event(s)`);
}
} catch (err) {
+ state.mcCompletedCommandIds.unshift(...completedCommandIds);
this.logService.warn(`[CopilotCLISession] MC event submission error: ${err}`);
}
}
@@ -1423,7 +1544,7 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
if (!currentState || !currentState.mcSessionId) {
return;
}
- CopilotCLISession._pollMcCommandsStatic(currentState, missionControlApiClient, logService).catch(err => {
+ CopilotCLISession._pollMcCommandsStatic(sessionId, currentState, missionControlApiClient, logService).catch(err => {
logService.warn(`[CopilotCLISession] MC command poll failed: ${err}`);
});
}, 3000);
@@ -1444,14 +1565,21 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
* Poll Mission Control for pending commands and process them.
* Static method to avoid capturing a stale `this` reference.
*/
- private static async _pollMcCommandsStatic(state: McSharedState, missionControlApiClient: MissionControlApiClient, logService: { info(msg: string): void; warn(msg: string): void }): Promise {
+ private static async _pollMcCommandsStatic(sessionId: string, state: McSharedState, missionControlApiClient: MissionControlApiClient, logService: { info(msg: string): void; warn(msg: string): void }): Promise {
try {
const commands = await missionControlApiClient.getPendingCommands(state.mcSessionId);
+ const pendingCommandIds = new Set(commands.map(cmd => cmd.id));
+ for (const processedId of state.mcProcessedCommandIds) {
+ if (!pendingCommandIds.has(processedId)) {
+ state.mcProcessedCommandIds.delete(processedId);
+ }
+ }
for (const cmd of commands) {
- if (cmd.state !== 'in_progress') {
+ if (cmd.state !== 'in_progress' || state.mcProcessedCommandIds.has(cmd.id)) {
continue;
}
+ state.mcProcessedCommandIds.add(cmd.id);
logService.info(`[CopilotCLISession] Processing MC command: ${cmd.type ?? 'user_message'} (${cmd.id})`);
switch (cmd.type) {
@@ -1478,6 +1606,12 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
// Route steering messages through the VS Code chat UI so
// they appear in the chat panel with proper rendering.
const vsCodeApi = require('vscode') as typeof import('vscode');
+ getMissionControlPendingCommandCompletionIds(state).add(cmd.id);
+ setPendingCopilotCLIRequestContext(sessionId, {
+ prompt: cmd.content,
+ attachments: [],
+ source: `command-${cmd.id}`,
+ });
vsCodeApi.commands.executeCommand(
'workbench.action.chat.openSessionWithPrompt.copilotcli',
{
@@ -1485,14 +1619,18 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
prompt: cmd.content,
}
).then(undefined, err => {
+ clearPendingCopilotCLIRequestContext(sessionId);
+ getMissionControlPendingCommandCompletionIds(state).delete(cmd.id);
+ state.mcCompletedCommandIds.push(cmd.id);
logService.warn(`[CopilotCLISession] MC steering send failed: ${err}`);
});
break;
}
}
- // Mark command as processed
- state.mcCompletedCommandIds.push(cmd.id);
+ if (cmd.type !== 'user_message' && cmd.type !== undefined) {
+ state.mcCompletedCommandIds.push(cmd.id);
+ }
}
} catch {
// Silently ignore polling errors
diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/missionControlApiClient.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/missionControlApiClient.ts
index 6595116521d36..827a0c13c1313 100644
--- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/missionControlApiClient.ts
+++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/missionControlApiClient.ts
@@ -20,6 +20,7 @@ export interface McEvent {
id: string;
timestamp: string;
parentId: string | null;
+ ephemeral?: boolean;
type: string;
data: Record;
}
diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts
index 72ed404c49281..cae2cbb89e22c 100644
--- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts
+++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts
@@ -106,7 +106,7 @@ class MockSdkSession {
// placeholder for user input responses
}
- public lastSendOptions: { prompt: string; mode?: string } | undefined;
+ public lastSendOptions: { prompt: string; mode?: string; source?: string } | undefined;
public currentMode: string | undefined;
async send(options: { prompt: string; mode?: string }) {
@@ -696,6 +696,8 @@ describe('CopilotCLISession', () => {
mcFlushInterval: undefined,
mcPollInterval: undefined,
mcLastEventId: null,
+ mcLastSubmitAttemptTimeMs: Date.now(),
+ mcProcessedCommandIds: new Set(),
mcSdkSession: sdkSession as unknown as Session,
mcEventListenerDispose: undefined,
mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,
@@ -713,6 +715,7 @@ describe('CopilotCLISession', () => {
await new Promise(r => setTimeout(r, 0));
await (CopilotCLISession as any)._pollMcCommandsStatic(
+ session.sessionId,
remoteState,
{
getPendingCommands: async () => [{
@@ -762,6 +765,8 @@ describe('CopilotCLISession', () => {
mcFlushInterval: undefined,
mcPollInterval: undefined,
mcLastEventId: null,
+ mcLastSubmitAttemptTimeMs: Date.now(),
+ mcProcessedCommandIds: new Set(),
mcSdkSession: sdkSession as unknown as Session,
mcEventListenerDispose: undefined,
mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,
@@ -795,6 +800,8 @@ describe('CopilotCLISession', () => {
mcFlushInterval: undefined,
mcPollInterval: undefined,
mcLastEventId: null,
+ mcLastSubmitAttemptTimeMs: Date.now(),
+ mcProcessedCommandIds: new Set(),
mcSdkSession: sdkSession as unknown as Session,
mcEventListenerDispose: undefined,
mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,
@@ -807,6 +814,49 @@ describe('CopilotCLISession', () => {
expect((remoteState.mcEventBuffer[0] as { type: string }).type).toBe('session.idle');
});
+ it('forwards session.title_changed to Mission Control as an ephemeral event', async () => {
+ const session = await createSession();
+ const remoteState = {
+ mcSessionId: 'mc-session',
+ mcEventBuffer: [],
+ mcCompletedCommandIds: [],
+ mcPendingPermissionRequests: new Map(),
+ mcFlushInterval: undefined,
+ mcPollInterval: undefined,
+ mcLastEventId: null,
+ mcLastSubmitAttemptTimeMs: Date.now(),
+ mcProcessedCommandIds: new Set(),
+ mcSdkSession: sdkSession as unknown as Session,
+ mcEventListenerDispose: undefined,
+ mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,
+ };
+ Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });
+
+ (session as any)._bufferMcEvent({
+ type: 'session.title_changed',
+ id: 'title-change-1',
+ timestamp: '2026-01-01T00:00:00.000Z',
+ parentId: 'visible-root-message',
+ ephemeral: true,
+ data: { title: 'Remote Session Title' },
+ });
+
+ expect(remoteState.mcEventBuffer).toHaveLength(1);
+ expect((remoteState.mcEventBuffer[0] as { type: string; ephemeral?: true }).type).toBe('session.title_changed');
+ expect((remoteState.mcEventBuffer[0] as { ephemeral?: true }).ephemeral).toBe(true);
+ expect((remoteState.mcEventBuffer[0] as { data: { title: string } }).data.title).toBe('Remote Session Title');
+ });
+
+ it('prefers existing session history over the current /remote prompt when deriving the Mission Control title', async () => {
+ const session = await createSession();
+ vi.spyOn(sdkSession, 'getEvents').mockReturnValue([
+ { type: 'user.message', data: { content: 'hey' } },
+ ] as any);
+ (session as any)._pendingPrompt = '/remote';
+
+ await expect((session as any)._getMissionControlSessionTitle()).resolves.toBe('hey');
+ });
+
it('does not forward report_intent tool events to Mission Control', async () => {
const session = await createSession();
const remoteState = {
@@ -817,6 +867,8 @@ describe('CopilotCLISession', () => {
mcFlushInterval: undefined,
mcPollInterval: undefined,
mcLastEventId: null,
+ mcLastSubmitAttemptTimeMs: Date.now(),
+ mcProcessedCommandIds: new Set(),
mcSdkSession: sdkSession as unknown as Session,
mcEventListenerDispose: undefined,
mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,
@@ -841,6 +893,136 @@ describe('CopilotCLISession', () => {
expect((remoteState.mcEventBuffer[0] as { data: { toolName: string } }).data.toolName).toBe('bash');
});
+ it('forwards command-sourced user messages and acknowledges the command with the echoed turn', async () => {
+ const session = await createSession();
+ const remoteState = {
+ mcSessionId: 'mc-session',
+ mcEventBuffer: [],
+ mcCompletedCommandIds: [],
+ mcPendingPermissionRequests: new Map(),
+ mcFlushInterval: undefined,
+ mcPollInterval: undefined,
+ mcLastEventId: null,
+ mcLastSubmitAttemptTimeMs: Date.now(),
+ mcProcessedCommandIds: new Set(),
+ mcPendingCommandCompletionIds: new Set(['mc-command-1']),
+ mcSdkSession: sdkSession as unknown as Session,
+ mcEventListenerDispose: undefined,
+ mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,
+ };
+ Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });
+
+ (session as any)._bufferMcEvent({
+ type: 'user.message',
+ id: 'remote-command-message',
+ timestamp: '2026-01-01T00:00:00.000Z',
+ parentId: 'visible-root-message',
+ data: { content: 'hey', source: 'command-mc-command-1' },
+ });
+ expect(remoteState.mcCompletedCommandIds).toEqual(['mc-command-1']);
+
+ (session as any)._bufferMcEvent({
+ type: 'assistant.message',
+ id: 'assistant-reply',
+ timestamp: '2026-01-01T00:00:01.000Z',
+ parentId: 'remote-command-message',
+ data: { content: 'Hello! How can I help you today?' },
+ });
+
+ expect(remoteState.mcEventBuffer).toHaveLength(2);
+ expect((remoteState.mcEventBuffer[0] as { type: string }).type).toBe('user.message');
+ expect((remoteState.mcEventBuffer[0] as { data: { content: string } }).data.content).toBe('hey');
+ expect((remoteState.mcEventBuffer[1] as { type: string; parentId: string | null }).type).toBe('assistant.message');
+ expect((remoteState.mcEventBuffer[1] as { parentId: string | null }).parentId).toBe('remote-command-message');
+ });
+
+ it('forwards remote command source to the SDK send options', async () => {
+ const session = await createSession();
+ const stream = new MockChatResponseStream();
+ session.attachStream(stream);
+
+ await session.handleRequest(
+ { id: '', toolInvocationToken: undefined as never },
+ { prompt: 'hey', source: 'command-mc-command-1' },
+ [],
+ undefined,
+ authInfo,
+ CancellationToken.None
+ );
+
+ expect(sdkSession.lastSendOptions?.source).toBe('command-mc-command-1');
+ });
+
+ it('flushes completed Mission Control command ids even when there are no buffered events', async () => {
+ const session = await createSession();
+ const submitEvents = vi.fn(async () => true);
+ const remoteState = {
+ mcSessionId: 'mc-session',
+ mcEventBuffer: [],
+ mcCompletedCommandIds: ['mc-command-1'],
+ mcPendingPermissionRequests: new Map(),
+ mcFlushInterval: undefined,
+ mcPollInterval: undefined,
+ mcLastEventId: null,
+ mcLastSubmitAttemptTimeMs: Date.now(),
+ mcProcessedCommandIds: new Set(),
+ mcSdkSession: sdkSession as unknown as Session,
+ mcEventListenerDispose: undefined,
+ mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,
+ };
+ Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });
+ Object.defineProperty(session, '_missionControlApiClient', {
+ value: { submitEvents },
+ configurable: true,
+ });
+
+ await (session as any)._flushMcEvents();
+
+ expect(submitEvents).toHaveBeenCalledWith('mc-session', [], ['mc-command-1']);
+ expect(remoteState.mcCompletedCommandIds).toEqual([]);
+ });
+
+ it('announces remote control disabled to Mission Control before detaching locally', async () => {
+ const session = await createSession();
+ const submitEvents = vi.fn(async () => true);
+ const deleteSession = vi.fn(async () => undefined);
+ const pendingRequest = vi.fn();
+ const mcEventListenerDispose = vi.fn();
+ const remoteState = {
+ mcSessionId: 'mc-session',
+ mcEventBuffer: [],
+ mcCompletedCommandIds: [],
+ mcPendingPermissionRequests: new Map([['prompt-1', { resolve: pendingRequest }]]),
+ mcFlushInterval: undefined,
+ mcPollInterval: undefined,
+ mcLastEventId: null,
+ mcLastSubmitAttemptTimeMs: Date.now(),
+ mcProcessedCommandIds: new Set(),
+ mcSdkSession: sdkSession as unknown as Session,
+ mcEventListenerDispose,
+ mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,
+ };
+ Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });
+ Object.defineProperty(session, '_missionControlApiClient', {
+ value: { submitEvents, deleteSession },
+ configurable: true,
+ });
+
+ await (session as any)._teardownRemoteControl();
+
+ expect(pendingRequest).toHaveBeenCalledWith({ kind: 'denied-interactively-by-user' });
+ expect(mcEventListenerDispose).toHaveBeenCalledTimes(1);
+ expect(submitEvents).toHaveBeenCalledWith(
+ 'mc-session',
+ expect.arrayContaining([
+ expect.objectContaining({ type: 'session.remote_steerable_changed', data: { remoteSteerable: false } }),
+ expect.objectContaining({ type: 'session.idle', data: {} }),
+ ]),
+ [],
+ );
+ expect(deleteSession).not.toHaveBeenCalled();
+ });
+
it('immediately pushes invocation messages for non-permission-requiring tools like MCP', async () => {
let resolveSend: () => void;
sdkSession.send = async () => new Promise(r => { resolveSend = r; });
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts
index c54f4369df5cc..3206284c51e4d 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts
@@ -2,7 +2,7 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
-import type { Attachment, SessionOptions, SweCustomAgent } from '@github/copilot/sdk';
+import type { Attachment, SendOptions, SessionOptions, SweCustomAgent } from '@github/copilot/sdk';
import * as l10n from '@vscode/l10n';
import * as vscode from 'vscode';
import { ChatExtendedRequestHandler, ChatRequestTurn2, Uri } from 'vscode';
@@ -35,6 +35,7 @@ import { IChatFolderMruService, IFolderRepositoryManager, IsolationMode } from '
import { getWorkingDirectory, IWorkspaceInfo } from '../common/workspaceInfo';
import { ICustomSessionTitleService } from '../copilotcli/common/customSessionTitleService';
import { IChatDelegationSummaryService } from '../copilotcli/common/delegationSummaryService';
+import { clearPendingCopilotCLIRequestContext, setPendingCopilotCLIRequestContext, takePendingCopilotCLIRequestContext } from '../copilotcli/common/pendingRequestContext';
import { SessionIdForCLI } from '../copilotcli/common/utils';
import { getCopilotCLISessionDir } from '../copilotcli/node/cliHelpers';
import { ICopilotCLISDK } from '../copilotcli/node/copilotCli';
@@ -672,8 +673,6 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
return this.handleRequest.bind(this);
}
- private readonly contextForRequest = new Map();
-
/**
* Outer request handler that supports *yielding* for session steering.
*
@@ -755,19 +754,19 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
* Resolve the input and attachments for the SDK session based on request type.
*
* The VS Code chat API creates the session before firing the request handler,
- * so delegated requests pre-resolve and cache prompt/attachments in `contextForRequest`.
+ * so delegated or remotely-steered requests pre-resolve and cache their prompt metadata
+ * before the handler runs.
*/
private async resolveInput(
request: vscode.ChatRequest,
session: ICopilotCLISession,
isNewSession: boolean,
token: vscode.CancellationToken,
- ): Promise<{ input: { prompt: string; command?: CopilotCLICommand }; attachments: Attachment[] }> {
- const contextForRequest = this.contextForRequest.get(session.sessionId);
- this.contextForRequest.delete(session.sessionId);
+ ): Promise<{ input: { prompt: string; command?: CopilotCLICommand; source?: SendOptions['source'] }; attachments: Attachment[] }> {
+ const contextForRequest = takePendingCopilotCLIRequestContext(session.sessionId);
if (contextForRequest) {
- return { input: { prompt: contextForRequest.prompt }, attachments: contextForRequest.attachments };
+ return { input: { prompt: contextForRequest.prompt, source: contextForRequest.source }, attachments: contextForRequest.attachments };
}
if (request.command && !request.prompt && !isNewSession) {
@@ -956,11 +955,14 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
}
}
- this.contextForRequest.set(session.object.sessionId, { prompt, attachments });
+ setPendingCopilotCLIRequestContext(session.object.sessionId, { prompt, attachments });
void vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {
resource: SessionIdForCLI.getResource(session.object.sessionId),
prompt: userPrompt || request.prompt,
attachedContext: references.map(ref => convertReferenceToVariable(ref, attachments))
+ }).then(undefined, error => {
+ clearPendingCopilotCLIRequestContext(session.object.sessionId);
+ this.logService.error(error, '[CopilotCLIChatSessionContentProvider] Failed to open Copilot CLI session');
});
stream.markdown(l10n.t('A Copilot CLI session has begun working on your request. Follow its progress in the sessions list.'));
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts
index 88b9c494e327b..4c7d156c7e8b9 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts
@@ -1391,6 +1391,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
inputState: {
groups: [],
sessionResource: undefined,
+ onDidDispose: Event.None,
onDidChange: Event.None
}
};
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts
index 1dae596a6ca04..8f36f0ed19a24 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts
@@ -84,6 +84,7 @@ beforeAll(() => {
groups,
sessionResource: undefined,
onDidChange: emitter.event,
+ onDidDispose: Event.None,
};
// Proxy that fires onDidChange when groups are replaced
return new Proxy(state, {
@@ -325,7 +326,7 @@ async function runHandlerAndCapture(
resource: ClaudeSessionUri.forSessionId(sessionId),
label: 'Test Session',
},
- inputState: { groups, sessionResource: undefined, onDidChange: Event.None },
+ inputState: { groups, sessionResource: undefined, onDidChange: Event.None, onDidDispose: Event.None },
},
} as vscode.ChatContext;
@@ -660,7 +661,7 @@ describe('ChatSessionContentProvider', () => {
resource: ClaudeSessionUri.forSessionId(sessionId),
label: 'Test Session',
},
- inputState: { groups: buildInputStateGroups(options), sessionResource: undefined, onDidChange: Event.None },
+ inputState: { groups: buildInputStateGroups(options), sessionResource: undefined, onDidChange: Event.None, onDidDispose: Event.None },
},
} as vscode.ChatContext;
}
@@ -775,6 +776,7 @@ describe('ChatSessionContentProvider', () => {
}),
sessionResource: undefined,
onDidChange: Event.None,
+ onDidDispose: Event.None,
},
},
} as vscode.ChatContext;
@@ -845,7 +847,7 @@ describe('ChatSessionContentProvider', () => {
resource: ClaudeSessionUri.forSessionId(sessionId),
label: 'Test Session',
},
- inputState: { groups: buildInputStateGroups(), sessionResource: undefined, onDidChange: Event.None },
+ inputState: { groups: buildInputStateGroups(), sessionResource: undefined, onDidChange: Event.None, onDidDispose: Event.None },
},
} as vscode.ChatContext;
}
@@ -945,7 +947,7 @@ describe('ChatSessionContentProvider', () => {
resource: ClaudeSessionUri.forSessionId(sessionId),
label: 'Test Session',
},
- inputState: { groups: buildInputStateGroups(), sessionResource: undefined, onDidChange: Event.None },
+ inputState: { groups: buildInputStateGroups(), sessionResource: undefined, onDidChange: Event.None, onDidDispose: Event.None },
},
} as vscode.ChatContext;
}
@@ -1163,6 +1165,7 @@ describe('ChatSessionContentProvider', () => {
groups: lockedGroups,
sessionResource: undefined,
onDidChange: Event.None,
+ onDidDispose: Event.None,
};
// sanity check
expect(initialGroup.items.map(i => i.id)).toEqual([folderA.fsPath, folderB.fsPath]);
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts
index 270be0dde4965..332ea490a6982 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts
@@ -106,6 +106,7 @@ function makeRef(name: string, type: number = 0 /* Head */): { name: string; typ
function createMockChatSessionInputState(groups: readonly vscode.ChatSessionProviderOptionGroup[]): vscode.ChatSessionInputState {
return {
+ onDidDispose: Event.None,
onDidChange: Event.None,
groups,
sessionResource: undefined
diff --git a/extensions/copilot/src/extension/chronicle/common/sessionIndexingPreference.ts b/extensions/copilot/src/extension/chronicle/common/sessionIndexingPreference.ts
index 404adb2c5cb10..c7d3d2de9605e 100644
--- a/extensions/copilot/src/extension/chronicle/common/sessionIndexingPreference.ts
+++ b/extensions/copilot/src/extension/chronicle/common/sessionIndexingPreference.ts
@@ -16,14 +16,6 @@ export type SessionIndexingLevel = 'local' | 'user' | 'repo_and_user';
/**
* Manages user preferences for session indexing via VS Code settings.
- *
- * Two settings control behavior:
- * - `chat.sessionSearch.localIndex.enabled` (team-internal, ExP) — enables local
- * SQLite tracking and /chronicle commands
- * - `chat.sessionSearch.cloudSync.enabled` — enables
- * cloud upload to cloud
- * - `chat.sessionSearch.cloudSync.excludeRepositories` — repo patterns
- * to exclude from cloud sync
*/
export class SessionIndexingPreference {
diff --git a/extensions/copilot/src/extension/chronicle/common/test/sessionIndexingPreference.spec.ts b/extensions/copilot/src/extension/chronicle/common/test/sessionIndexingPreference.spec.ts
index e5b07ca575163..82568f03d9dd0 100644
--- a/extensions/copilot/src/extension/chronicle/common/test/sessionIndexingPreference.spec.ts
+++ b/extensions/copilot/src/extension/chronicle/common/test/sessionIndexingPreference.spec.ts
@@ -13,7 +13,7 @@ function createMockConfigService(opts: {
} = {}) {
const configs: Record = {};
// Map by fullyQualifiedId
- configs['github.copilot.chat.advanced.sessionSearch.localIndex.enabled'] = opts.localIndexEnabled ?? false;
+ configs['github.copilot.chat.localIndex.enabled'] = opts.localIndexEnabled ?? false;
configs['github.copilot.chat.advanced.sessionSearch.cloudSync.enabled'] = opts.cloudSyncEnabled ?? false;
configs['github.copilot.chat.advanced.sessionSearch.cloudSync.excludeRepositories'] = opts.excludeRepositories ?? [];
diff --git a/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts b/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts
index c469e4e8d0fcd..35a51719c5b21 100644
--- a/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts
+++ b/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts
@@ -120,7 +120,7 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr
// Only set up span listener when both local index and cloud sync are enabled.
// Uses autorun to react if settings change at runtime.
- const localEnabled = this._configService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.SessionSearchLocalIndexEnabled, this._expService);
+ const localEnabled = this._configService.getExperimentBasedConfigObservable(ConfigKey.LocalIndexEnabled, this._expService);
const cloudEnabled = this._configService.getConfigObservable(ConfigKey.TeamInternal.SessionSearchCloudSyncEnabled);
const spanListenerStore = this._register(new DisposableStore());
this._register(autorun(reader => {
diff --git a/extensions/copilot/src/extension/chronicle/vscode-node/sessionStoreTracker.ts b/extensions/copilot/src/extension/chronicle/vscode-node/sessionStoreTracker.ts
index 993f0d134d36c..43a432f2002a6 100644
--- a/extensions/copilot/src/extension/chronicle/vscode-node/sessionStoreTracker.ts
+++ b/extensions/copilot/src/extension/chronicle/vscode-node/sessionStoreTracker.ts
@@ -80,7 +80,7 @@ export class SessionStoreTracker extends Disposable implements IExtensionContrib
// Only set up span listener and flush timer when the feature is enabled.
// Uses autorun to react if the setting changes at runtime.
- const featureEnabled = this._configService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.SessionSearchLocalIndexEnabled, this._expService);
+ const featureEnabled = this._configService.getExperimentBasedConfigObservable(ConfigKey.LocalIndexEnabled, this._expService);
const spanListenerStore = this._register(new DisposableStore());
this._register(autorun(reader => {
spanListenerStore.clear();
diff --git a/extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts b/extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts
index 876efa5244a90..4b4dacf885518 100644
--- a/extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts
+++ b/extensions/copilot/src/extension/contextKeys/vscode-node/contextKeys.contribution.ts
@@ -85,7 +85,7 @@ export class ContextKeysContribution extends Disposable {
commands.executeCommand('setContext', debugReportFeedbackContextKey, debugReportFeedback.read(reader));
}));
- const sessionSearchEnabled = this._configService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.SessionSearchLocalIndexEnabled, this._expService);
+ const sessionSearchEnabled = this._configService.getExperimentBasedConfigObservable(ConfigKey.LocalIndexEnabled, this._expService);
this._register(autorun(reader => {
commands.executeCommand('setContext', sessionSearchEnabledContextKey, sessionSearchEnabled.read(reader));
}));
diff --git a/extensions/copilot/src/extension/intents/node/chronicleIntent.ts b/extensions/copilot/src/extension/intents/node/chronicleIntent.ts
index 05376f288602a..8f43160d47633 100644
--- a/extensions/copilot/src/extension/intents/node/chronicleIntent.ts
+++ b/extensions/copilot/src/extension/intents/node/chronicleIntent.ts
@@ -49,7 +49,7 @@ export class ChronicleIntent implements IIntent {
readonly id = ChronicleIntent.ID;
readonly description = l10n.t('Session history tools and insights (standup, tips, improve)');
get locations(): ChatLocation[] {
- return this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.SessionSearchLocalIndexEnabled, this._expService) ? [ChatLocation.Panel] : [];
+ return this._configService.getExperimentBasedConfig(ConfigKey.LocalIndexEnabled, this._expService) ? [ChatLocation.Panel] : [];
}
readonly commandInfo: IIntentSlashCommandInfo = {
@@ -86,7 +86,7 @@ export class ChronicleIntent implements IIntent {
location: ChatLocation,
chatTelemetry: ChatTelemetryBuilder,
): Promise {
- if (!this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.SessionSearchLocalIndexEnabled, this._expService)) {
+ if (!this._configService.getExperimentBasedConfig(ConfigKey.LocalIndexEnabled, this._expService)) {
stream.markdown(l10n.t('Session search is not available yet.'));
return {};
}
diff --git a/extensions/copilot/src/extension/tools/vscode-node/switchAgentTool.ts b/extensions/copilot/src/extension/tools/vscode-node/switchAgentTool.ts
index 9ab54f779b8b1..f0cf084656d1a 100644
--- a/extensions/copilot/src/extension/tools/vscode-node/switchAgentTool.ts
+++ b/extensions/copilot/src/extension/tools/vscode-node/switchAgentTool.ts
@@ -4,6 +4,8 @@
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
+import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
+import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService';
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
import { LanguageModelTextPart, LanguageModelToolResult, MarkdownString } from '../../../vscodeTypes';
import { PlanAgentProvider } from '../../agents/vscode-node/planAgentProvider';
@@ -18,6 +20,11 @@ export class SwitchAgentTool implements ICopilotTool {
public static readonly toolName = ToolName.SwitchAgent;
public static readonly nonDeferred = true;
+ constructor(
+ @IConfigurationService private readonly configurationService: IConfigurationService,
+ @IExperimentationService private readonly experimentationService: IExperimentationService,
+ ) { }
+
async invoke(options: vscode.LanguageModelToolInvocationOptions, token: CancellationToken): Promise {
const { agentName } = options.input;
@@ -26,7 +33,9 @@ export class SwitchAgentTool implements ICopilotTool {
throw new Error(vscode.l10n.t('Only "Plan" agent is supported'));
}
- const planAgentBody = PlanAgentProvider.buildAgentBody();
+ const exploreEnabled = this.configurationService.getExperimentBasedConfig(ConfigKey.ExploreAgentEnabled, this.experimentationService);
+ const searchSubagentEnabled = this.configurationService.getExperimentBasedConfig(ConfigKey.Advanced.SearchSubagentToolEnabled, this.experimentationService);
+ const planAgentBody = PlanAgentProvider.buildAgentBody(exploreEnabled, searchSubagentEnabled);
// Execute command to switch agent
await vscode.commands.executeCommand('workbench.action.chat.toggleAgentMode', {
diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts
index 936bb3a2c16f7..8857f9e44ffde 100644
--- a/extensions/copilot/src/platform/configuration/common/configurationService.ts
+++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts
@@ -875,8 +875,6 @@ export namespace ConfigKey {
export const ResponsesApiWebSocketEnabled = defineTeamInternalSetting('chat.advanced.responsesApi.webSocket.enabled', ConfigType.ExperimentBased, false);
export const DebugSimulateWebSocketResponse = defineTeamInternalSetting('chat.advanced.debug.simulateWebSocketResponse', ConfigType.Simple, '');
- /** Enable local session search index — tracks sessions locally and enables chronicle commands.*/
- export const SessionSearchLocalIndexEnabled = defineTeamInternalSetting('chat.advanced.sessionSearch.localIndex.enabled', ConfigType.ExperimentBased, false, vBoolean());
/** Enable cloud sync of session data to cloud. */
export const SessionSearchCloudSyncEnabled = defineTeamInternalSetting('chat.advanced.sessionSearch.cloudSync.enabled', ConfigType.Simple, false, vBoolean());
/** Repository patterns to exclude from cloud sync (exact owner/repo or glob patterns like my-org/*). */
@@ -1030,6 +1028,9 @@ export namespace ConfigKey {
export const CopilotMemoryEnabled = defineSetting('chat.copilotMemory.enabled', ConfigType.ExperimentBased, false);
export const MemoryToolEnabled = defineSetting('chat.tools.memory.enabled', ConfigType.ExperimentBased, true);
export const ViewImageToolEnabled = defineSetting('chat.tools.viewImage.enabled', ConfigType.ExperimentBased, true);
+
+ /** Enable local session search index — tracks sessions locally and enables chronicle commands.*/
+ export const LocalIndexEnabled = defineSetting('chat.localIndex.enabled', ConfigType.ExperimentBased, false);
}
export function getAllConfigKeys(): string[] {
diff --git a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts
index 24cc6056d0cf3..fc3c6469535bb 100644
--- a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts
+++ b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts
@@ -101,7 +101,8 @@ export function createMessagesRequestBody(accessor: ServicesAccessor, options: I
const experimentationService = accessor.get(IExperimentationService);
const toolDeferralService = accessor.get(IToolDeferralService);
- const toolSearchEnabled = !!endpoint.supportsToolSearch;
+ const toolSearchEnabled = !!endpoint.supportsToolSearch
+ && !!options.requestOptions?.tools?.some(t => t.function.name === CUSTOM_TOOL_SEARCH_NAME);
// Split tools into non-deferred and deferred up front so we can build finalTools
// with non-deferred first. This ensures the cache_control breakpoint on the last
diff --git a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts
index 700fb85ab48e2..a42223b0aa5ac 100644
--- a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts
+++ b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts
@@ -65,7 +65,8 @@ export function createResponsesRequestBody(accessor: ServicesAccessor, options:
const toolSearchEnabled = isResponsesApiToolSearchEnabled(endpoint, configService, expService);
const isAllowedConversationAgent = options.location === ChatLocation.Agent || options.location === ChatLocation.MessagesProxy;
const isSubagent = options.telemetryProperties?.subType?.startsWith('subagent') ?? false;
- const shouldDeferTools = toolSearchEnabled && isAllowedConversationAgent && !isSubagent;
+ const toolSearchInRequest = !!options.requestOptions?.tools?.some(t => t.function.name === CUSTOM_TOOL_SEARCH_NAME);
+ const shouldDeferTools = toolSearchEnabled && isAllowedConversationAgent && !isSubagent && toolSearchInRequest;
const toolDeferralService = shouldDeferTools ? accessor.get(IToolDeferralService) : undefined;
type ResponsesFunctionTool = OpenAI.Responses.FunctionTool & OpenAiResponsesFunctionTool;
diff --git a/extensions/copilot/src/platform/endpoint/node/test/responsesApiToolSearch.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/responsesApiToolSearch.spec.ts
index 3d4dd218aacec..728c106eac42a 100644
--- a/extensions/copilot/src/platform/endpoint/node/test/responsesApiToolSearch.spec.ts
+++ b/extensions/copilot/src/platform/endpoint/node/test/responsesApiToolSearch.spec.ts
@@ -57,6 +57,7 @@ function createMockOptions(overrides: Partial = {}):
{ type: 'function', function: { name: 'grep_search', description: 'Search for text', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } } },
{ type: 'function', function: { name: 'some_mcp_tool', description: 'An MCP tool', parameters: { type: 'object', properties: { input: { type: 'string' } }, required: ['input'] } } },
{ type: 'function', function: { name: 'another_deferred_tool', description: 'Another tool', parameters: { type: 'object', properties: {} } } },
+ { type: 'function', function: { name: 'tool_search', description: 'Search tools', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } } },
]
},
...overrides,
@@ -149,6 +150,35 @@ describe('createResponsesRequestBody tools', () => {
expect(tools.every(t => !t.defer_loading)).toBe(true);
});
+ it('does not defer tools when tool_search is not in the request tool list', () => {
+ // Repro for https://github.com/microsoft/vscode/issues/311946: a custom agent with
+ // `tools: ['my-mcp-server/*']` filters out tool_search. Without this gate, every
+ // MCP tool would be marked deferred and stripped from the request, leaving the
+ // agent with nothing to call.
+ const endpoint = createMockEndpoint('gpt-5.4-preview');
+ const configService = accessor.get(IConfigurationService) as InMemoryConfigurationService;
+ configService.setConfig(ConfigKey.ResponsesApiToolSearchEnabled, true);
+
+ const options = createMockOptions({
+ requestOptions: {
+ tools: [
+ { type: 'function', function: { name: 'some_mcp_tool', description: 'An MCP tool', parameters: { type: 'object', properties: {} } } },
+ { type: 'function', function: { name: 'another_mcp_tool', description: 'Another MCP tool', parameters: { type: 'object', properties: {} } } },
+ ]
+ }
+ });
+ const body = accessor.get(IInstantiationService).invokeFunction(
+ createResponsesRequestBody, options, endpoint.model, endpoint
+ );
+
+ const tools = body.tools as any[];
+ // No client tool_search should be added.
+ expect(tools.find(t => t.type === 'tool_search')).toBeUndefined();
+ // All user-listed tools should be sent to the model, not stripped.
+ expect(tools.find(t => t.name === 'some_mcp_tool')).toBeDefined();
+ expect(tools.find(t => t.name === 'another_mcp_tool')).toBeDefined();
+ });
+
it('always filters tool_search function tool from tools array', () => {
const endpoint = createMockEndpoint('gpt-5.4-preview');
const configService = accessor.get(IConfigurationService) as InMemoryConfigurationService;
diff --git a/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts b/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts
index da00bbb684bd2..798481a0466d5 100644
--- a/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts
+++ b/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts
@@ -819,3 +819,112 @@ describe('createMessagesRequestBody reasoning effort', () => {
expect(body.output_config).toEqual({ effort: 'low' });
});
});
+
+describe('createMessagesRequestBody tool search deferral', () => {
+ let disposables: DisposableStore;
+ let instantiationService: IInstantiationService;
+
+ function createMockEndpoint(supportsToolSearch: boolean): IChatEndpoint {
+ return {
+ model: 'claude-sonnet-4.6',
+ family: 'claude-sonnet-4.6',
+ modelProvider: 'Anthropic',
+ maxOutputTokens: 8192,
+ modelMaxPromptTokens: 200000,
+ supportsToolCalls: true,
+ supportsVision: true,
+ supportsPrediction: false,
+ supportsToolSearch,
+ showInModelPicker: true,
+ isFallback: false,
+ name: 'test',
+ version: '1.0',
+ policy: 'enabled',
+ urlOrRequestMetadata: 'https://test.com',
+ tokenizer: 0,
+ isDefault: false,
+ processResponseFromChatEndpoint: () => { throw new Error('not implemented'); },
+ acceptChatPolicy: () => { throw new Error('not implemented'); },
+ makeChatRequest2: () => { throw new Error('not implemented'); },
+ createRequestBody: () => { throw new Error('not implemented'); },
+ cloneWithTokenOverride: () => { throw new Error('not implemented'); },
+ interceptBody: () => { },
+ getExtraHeaders: () => ({}),
+ } as unknown as IChatEndpoint;
+ }
+
+ function makeTool(name: string) {
+ return { type: 'function' as const, function: { name, description: `${name} tool`, parameters: { type: 'object', properties: {} } } };
+ }
+
+ function createOptions(tools: ReturnType[]): ICreateEndpointBodyOptions {
+ return {
+ debugName: 'test',
+ requestId: 'test-request-id',
+ finishedCb: undefined,
+ messages: [{
+ role: Raw.ChatRole.User,
+ content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }],
+ }],
+ postOptions: { max_tokens: 8192 },
+ location: ChatLocation.Agent,
+ modelCapabilities: { enableToolSearch: true },
+ requestOptions: { tools },
+ } as ICreateEndpointBodyOptions;
+ }
+
+ beforeEach(() => {
+ disposables = new DisposableStore();
+ const services = disposables.add(createPlatformServices(disposables));
+ // Non-deferred allowlist matches production: core tools + tool_search itself.
+ const nonDeferred = new Set(['read_file', 'grep_search', CUSTOM_TOOL_SEARCH_NAME]);
+ services.define(IToolDeferralService, {
+ _serviceBrand: undefined,
+ isNonDeferredTool: (name: string) => nonDeferred.has(name),
+ });
+ const accessor = services.createTestingAccessor();
+ instantiationService = accessor.get(IInstantiationService);
+ });
+
+ test('does not set defer_loading when tool_search is not in the request tool list', () => {
+ // Repro for https://github.com/microsoft/vscode/issues/311946: a custom agent
+ // with `tools: ['my-mcp-server/*']` filters out tool_search. Without this gate,
+ // every MCP tool gets defer_loading=true and Anthropic rejects the request with
+ // "At least one tool must have defer_loading=false."
+ const endpoint = createMockEndpoint(true);
+ const options = createOptions([makeTool('some_mcp_tool'), makeTool('another_mcp_tool')]);
+
+ const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint);
+
+ const tools = body.tools as AnthropicMessagesTool[];
+ expect(tools.every(t => !t.defer_loading)).toBe(true);
+ expect(tools.find(t => t.name === 'some_mcp_tool')).toBeDefined();
+ expect(tools.find(t => t.name === 'another_mcp_tool')).toBeDefined();
+ });
+
+ test('defers MCP tools when tool_search is in the request tool list', () => {
+ const endpoint = createMockEndpoint(true);
+ const options = createOptions([
+ makeTool('read_file'),
+ makeTool('some_mcp_tool'),
+ makeTool(CUSTOM_TOOL_SEARCH_NAME),
+ ]);
+
+ const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint);
+
+ const tools = body.tools as AnthropicMessagesTool[];
+ expect(tools.find(t => t.name === 'read_file')?.defer_loading).toBeUndefined();
+ expect(tools.find(t => t.name === CUSTOM_TOOL_SEARCH_NAME)?.defer_loading).toBeUndefined();
+ expect(tools.find(t => t.name === 'some_mcp_tool')?.defer_loading).toBe(true);
+ });
+
+ test('does not defer when endpoint does not support tool search', () => {
+ const endpoint = createMockEndpoint(false);
+ const options = createOptions([makeTool('read_file'), makeTool('some_mcp_tool'), makeTool(CUSTOM_TOOL_SEARCH_NAME)]);
+
+ const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint);
+
+ const tools = body.tools as AnthropicMessagesTool[];
+ expect(tools.every(t => !t.defer_loading)).toBe(true);
+ });
+});
diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts
index 7e93cb0b298ad..6f8be91bd4fa6 100644
--- a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts
+++ b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts
@@ -340,7 +340,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string {
// Step 1: Write a sentinel file into the sandbox-provided $TMPDIR.
const writeOutput = await invokeRunInTerminal(`echo ${marker} > "$TMPDIR/${sentinelName}" && echo ${marker}`);
- assert.strictEqual(writeOutput.trim(), marker);
+ assert.ok(writeOutput.trim().endsWith(marker), `Unexpected output: ${JSON.stringify(writeOutput.trim())}`);
// Step 2: Retry with requestUnsandboxedExecution=true while sandbox
// stays enabled. The tool should preserve $TMPDIR from the sandbox so
@@ -351,7 +351,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string {
requestUnsandboxedExecutionReason: 'Need to verify $TMPDIR persists on unsandboxed retry',
});
const trimmed = retryOutput.trim();
- assert.ok(trimmed.startsWith('Note: The tool simplified the command to'), `Unexpected output: ${JSON.stringify(trimmed)}`);
+ assert.ok(trimmed.includes('Note: The tool simplified the command to'), `Unexpected output: ${JSON.stringify(trimmed)}`);
assert.ok(trimmed.includes(`cat "$TMPDIR/${sentinelName}"`), `Unexpected output: ${JSON.stringify(trimmed)}`);
assert.ok(trimmed.endsWith(marker), `Unexpected output: ${JSON.stringify(trimmed)}`);
});
@@ -378,13 +378,17 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string {
const trimmed = output.trim();
// macOS: "# List of acceptable shells for chpass(1)."
// Linux: "# /etc/shells: valid login shells"
+ // On headless Linux CI, Electron/Chromium may emit DBus stderr lines
+ // before the actual command output, so check the *last* line rather
+ // than requiring the whole trimmed buffer to start with '#'.
+ const lastLine = trimmed.split('\n').pop() ?? '';
assert.ok(
- trimmed.startsWith('#'),
+ lastLine.startsWith('#'),
`Expected a comment line from /etc/shells, got: ${trimmed}`
);
});
- test('can write inside the workspace folder', async function () {
+ test.skip('can write inside the workspace folder', async function () {
this.timeout(60000);
const marker = `SANDBOX_WS_${Date.now()}`;
@@ -399,7 +403,12 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string {
const marker = `SANDBOX_TMPDIR_${Date.now()}`;
const output = await invokeRunInTerminal(`echo "${marker}" > "$TMPDIR/${marker}.tmp" && cat "$TMPDIR/${marker}.tmp" && rm "$TMPDIR/${marker}.tmp"`);
- assert.strictEqual(output.trim(), marker);
+ // On headless Linux CI, Electron/Chromium may emit DBus stderr lines
+ // before the actual command output, so check the *last* line rather
+ // than requiring the entire trimmed output to equal the marker.
+ const trimmed = output.trim();
+ const lastLine = trimmed.split('\n').pop() ?? '';
+ assert.strictEqual(lastLine, marker, `Unexpected output: ${JSON.stringify(trimmed)}`);
});
test('non-allowlisted domains trigger unsandboxed confirmation flow', async function () {
diff --git a/package-lock.json b/package-lock.json
index b09cf211da1b7..477c2579e7626 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,7 +10,7 @@
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
- "@anthropic-ai/sandbox-runtime": "0.0.42",
+ "@anthropic-ai/sandbox-runtime": "0.0.49",
"@github/copilot": "^1.0.34",
"@github/copilot-sdk": "^0.2.2",
"@microsoft/1ds-core-js": "^3.2.13",
@@ -188,15 +188,13 @@
}
},
"node_modules/@anthropic-ai/sandbox-runtime": {
- "version": "0.0.42",
- "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.42.tgz",
- "integrity": "sha512-kJpuhU4hHMumeygIkKvNhscEsTtQK1sat1kZwhb6HLYBznwjMGOdnuBI/RM9HeFwxArn71/ciD2WJbxttXBMHw==",
+ "version": "0.0.49",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.49.tgz",
+ "integrity": "sha512-t8Ggc0A7UizxMGPk/ANEH8nwnCqzNWIKpkdKgxDVUaKNMQnMzzWR6aErrqIdU03/ZP5RN6/OL/kjFOw/Vox3KQ==",
"license": "Apache-2.0",
"dependencies": {
"@pondwader/socks5-server": "^1.0.10",
- "@types/lodash-es": "^4.17.12",
"commander": "^12.1.0",
- "lodash-es": "^4.17.23",
"shell-quote": "^1.8.3",
"zod": "^3.24.1"
},
@@ -2759,21 +2757,6 @@
"@types/node": "*"
}
},
- "node_modules/@types/lodash": {
- "version": "4.17.23",
- "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
- "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==",
- "license": "MIT"
- },
- "node_modules/@types/lodash-es": {
- "version": "4.17.12",
- "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
- "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
- "license": "MIT",
- "dependencies": {
- "@types/lodash": "*"
- }
- },
"node_modules/@types/minimatch": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
@@ -13308,12 +13291,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/lodash-es": {
- "version": "4.18.1",
- "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
- "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
- "license": "MIT"
- },
"node_modules/lodash.camelcase": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
diff --git a/package.json b/package.json
index 8d32f1f41eca0..829ab071f3f2f 100644
--- a/package.json
+++ b/package.json
@@ -89,7 +89,7 @@
"install-latest-component-explorer": "npm install @vscode/component-explorer@next @vscode/component-explorer-cli@next && cd build/rspack && npm install @vscode/component-explorer-webpack-plugin@next @vscode/component-explorer@next && cd ../vite && npm install @vscode/component-explorer-vite-plugin@next @vscode/component-explorer@next"
},
"dependencies": {
- "@anthropic-ai/sandbox-runtime": "0.0.42",
+ "@anthropic-ai/sandbox-runtime": "0.0.49",
"@github/copilot": "^1.0.34",
"@github/copilot-sdk": "^0.2.2",
"@microsoft/1ds-core-js": "^3.2.13",
diff --git a/remote/package-lock.json b/remote/package-lock.json
index 65394f951c81e..5cd9518aae58f 100644
--- a/remote/package-lock.json
+++ b/remote/package-lock.json
@@ -8,7 +8,7 @@
"name": "vscode-reh",
"version": "0.0.0",
"dependencies": {
- "@anthropic-ai/sandbox-runtime": "0.0.42",
+ "@anthropic-ai/sandbox-runtime": "0.0.49",
"@github/copilot": "^1.0.34",
"@github/copilot-sdk": "^0.2.2",
"@microsoft/1ds-core-js": "^3.2.13",
@@ -54,15 +54,13 @@
}
},
"node_modules/@anthropic-ai/sandbox-runtime": {
- "version": "0.0.42",
- "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.42.tgz",
- "integrity": "sha512-kJpuhU4hHMumeygIkKvNhscEsTtQK1sat1kZwhb6HLYBznwjMGOdnuBI/RM9HeFwxArn71/ciD2WJbxttXBMHw==",
+ "version": "0.0.49",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.49.tgz",
+ "integrity": "sha512-t8Ggc0A7UizxMGPk/ANEH8nwnCqzNWIKpkdKgxDVUaKNMQnMzzWR6aErrqIdU03/ZP5RN6/OL/kjFOw/Vox3KQ==",
"license": "Apache-2.0",
"dependencies": {
"@pondwader/socks5-server": "^1.0.10",
- "@types/lodash-es": "^4.17.12",
"commander": "^12.1.0",
- "lodash-es": "^4.17.23",
"shell-quote": "^1.8.3",
"zod": "^3.24.1"
},
@@ -582,21 +580,6 @@
"node": ">= 10"
}
},
- "node_modules/@types/lodash": {
- "version": "4.17.23",
- "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
- "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==",
- "license": "MIT"
- },
- "node_modules/@types/lodash-es": {
- "version": "4.17.12",
- "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
- "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
- "license": "MIT",
- "dependencies": {
- "@types/lodash": "*"
- }
- },
"node_modules/@vscode/deviceid": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@vscode/deviceid/-/deviceid-0.1.4.tgz",
@@ -1219,12 +1202,6 @@
"node": ">=12.9.0"
}
},
- "node_modules/lodash-es": {
- "version": "4.18.1",
- "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
- "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
- "license": "MIT"
- },
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
diff --git a/remote/package.json b/remote/package.json
index 0143b6c5ce3d6..123ff2899d7f7 100644
--- a/remote/package.json
+++ b/remote/package.json
@@ -3,7 +3,7 @@
"version": "0.0.0",
"private": true,
"dependencies": {
- "@anthropic-ai/sandbox-runtime": "0.0.42",
+ "@anthropic-ai/sandbox-runtime": "0.0.49",
"@github/copilot": "^1.0.34",
"@github/copilot-sdk": "^0.2.2",
"@microsoft/1ds-core-js": "^3.2.13",
diff --git a/resources/linux/debian/postinst.template b/resources/linux/debian/postinst.template
index 5ee1ebb5af529..fa21fc470f1a0 100755
--- a/resources/linux/debian/postinst.template
+++ b/resources/linux/debian/postinst.template
@@ -29,13 +29,35 @@ if [ "@@NAME@@" != "code-oss" ]; then
fi
# Register apt repository
- eval $(apt-config shell APT_SOURCE_PARTS Dir::Etc::sourceparts/d)
+ eval $(apt-config shell APT_SOURCE_PARTS Dir::Etc::sourceparts/d APT_SOURCES_LIST Dir::Etc::sourcelist/f)
CODE_SOURCE_PART=${APT_SOURCE_PARTS}vscode.list
CODE_SOURCE_PART_DEB822=${APT_SOURCE_PARTS}vscode.sources
CODE_TRUSTED_PART=/usr/share/keyrings/microsoft.gpg
CODE_TRUSTED_PART_OLD="/etc/apt/trusted.gpg.d/microsoft.gpg"
+ has_existing_repo_source() {
+ for source_file in "${APT_SOURCE_PARTS}"*.list "${APT_SOURCE_PARTS}"*.sources "$APT_SOURCES_LIST"; do
+ if [ ! -f "$source_file" ]; then
+ continue
+ fi
+
+ # Classic apt source list entry, for example:
+ # deb [arch=amd64] https://packages.microsoft.com/repos/code stable main
+ if grep -Eiq "^[[:space:]]*deb[[:space:]].*https://packages\\.microsoft\\.com/repos/code/?([[:space:]]|$)" "$source_file"; then
+ return 0
+ fi
+
+ # DEB822 source entry, for example:
+ # URIs: https://packages.microsoft.com/repos/code
+ if grep -Eiq "^[[:space:]]*URIs:[[:space:]]*https://packages\\.microsoft\\.com/repos/code/?([[:space:]]|$)" "$source_file"; then
+ return 0
+ fi
+ done
+
+ return 1
+ }
+
# RET seems to be true by default even after db_get is called on a first install.
RET='true'
if [ -e '/usr/share/debconf/confmodule' ]; then
@@ -54,6 +76,9 @@ if [ "@@NAME@@" != "code-oss" ]; then
elif [ -f "$CODE_SOURCE_PART_DEB822" ]; then
# The user is on the new DEB822 format, but refresh the file contents
WRITE_SOURCE='yes'
+ elif has_existing_repo_source; then
+ # Another source list file already maps to this repository
+ WRITE_SOURCE='no'
elif [ -f /etc/rpi-issue ]; then
# Do not write on Raspberry Pi OS
# https://github.com/microsoft/vscode/issues/118825
diff --git a/src/vs/code/electron-main/main.ts b/src/vs/code/electron-main/main.ts
index e5c7bf736e29b..ae28c78c219cb 100644
--- a/src/vs/code/electron-main/main.ts
+++ b/src/vs/code/electron-main/main.ts
@@ -200,7 +200,7 @@ class CodeMain {
services.set(IStateService, stateService);
// User Data Profiles
- const userDataProfilesMainService = new UserDataProfilesMainService(stateService, uriIdentityService, environmentMainService, fileService, logService);
+ const userDataProfilesMainService = new UserDataProfilesMainService(stateService, uriIdentityService, environmentMainService, fileService, logService, productService);
services.set(IUserDataProfilesMainService, userDataProfilesMainService);
// Use FileUserDataProvider for user data to
diff --git a/src/vs/code/node/cli.ts b/src/vs/code/node/cli.ts
index 43ea73d798b41..2cfef361c3bdf 100644
--- a/src/vs/code/node/cli.ts
+++ b/src/vs/code/node/cli.ts
@@ -249,12 +249,14 @@ export async function main(argv: string[]): Promise {
const tempUserDataDir = join(tempParentDir, 'data');
const tempExtensionsDir = join(tempParentDir, 'extensions');
const tempSharedDataDir = join(tempParentDir, 'shared');
+ const tempAgentPluginsDir = join(tempParentDir, 'agent-plugins');
addArg(argv, '--user-data-dir', tempUserDataDir);
addArg(argv, '--extensions-dir', tempExtensionsDir);
addArg(argv, '--shared-data-dir', tempSharedDataDir);
+ addArg(argv, '--agent-plugins-dir', tempAgentPluginsDir);
- console.log(`State is temporarily stored. Relaunch this state with: ${product.applicationName} --user-data-dir "${tempUserDataDir}" --extensions-dir "${tempExtensionsDir}" --shared-data-dir "${tempSharedDataDir}"`);
+ console.log(`State is temporarily stored. Relaunch this state with: ${product.applicationName} --user-data-dir "${tempUserDataDir}" --extensions-dir "${tempExtensionsDir}" --shared-data-dir "${tempSharedDataDir}" --agent-plugins-dir "${tempAgentPluginsDir}"`);
}
const hasReadStdinArg = args._.some(arg => arg === '-') || args.chat?._.some(arg => arg === '-');
diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts
index bf286e095d4a6..4ddc4975a3a74 100644
--- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts
+++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts
@@ -20,7 +20,7 @@ import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentResolv
import { AgentSubscriptionManager, type IAgentSubscription } from '../common/state/agentSubscription.js';
import { agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../common/agentHostUri.js';
import type { ClientNotificationMap, CommandMap, JsonRpcErrorResponse, JsonRpcRequest } from '../common/state/protocol/messages.js';
-import type { ActionEnvelope, INotification, SessionAction, TerminalAction } from '../common/state/sessionActions.js';
+import type { ActionEnvelope, INotification, RootAction, SessionAction, TerminalAction } from '../common/state/sessionActions.js';
import { SessionSummary, SessionStatus, ROOT_STATE_URI, StateComponents, type RootState } from '../common/state/sessionState.js';
import { PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js';
import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, type ProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js';
@@ -182,7 +182,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
return this._subscriptionManager.getSubscriptionUnmanaged(resource);
}
- dispatch(action: SessionAction | TerminalAction): void {
+ dispatch(action: RootAction | SessionAction | TerminalAction): void {
const seq = this._subscriptionManager.dispatchOptimistic(action);
this.dispatchAction(action, this._clientId, seq);
}
@@ -205,7 +205,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
/**
* Dispatch a client action to the server. Returns the clientSeq used.
*/
- dispatchAction(action: SessionAction | TerminalAction, _clientId: string, clientSeq: number): void {
+ dispatchAction(action: RootAction | SessionAction | TerminalAction, _clientId: string, clientSeq: number): void {
this._sendNotification('dispatchAction', { clientSeq, action });
}
diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts
index 9a7dcfd63b235..33ebc3dacf585 100644
--- a/src/vs/platform/agentHost/common/agentHostSchema.ts
+++ b/src/vs/platform/agentHost/common/agentHostSchema.ts
@@ -95,18 +95,29 @@ export interface ISchema {
*/
assertValid(key: K, value: unknown): asserts value is SchemaValue;
/**
- * Returns a fully-typed values bag by validating each key of
- * `defaults` against `values` and falling back to the default when
+ * Returns a fully-typed values bag by validating each key of the
+ * schema against `values` and falling back to the default when
* the incoming value is missing or fails validation.
*
+ * Semantics: for every key declared in the schema `definition`:
+ * - if `values[key]` validates, it is kept;
+ * - else if `key` is present in `defaults`, the default is used;
+ * - else the key is omitted from the result.
+ *
+ * This means callers MAY supply defaults for only a subset of the
+ * schema — keys not present in `defaults` are simply left unset
+ * when the incoming value is missing or invalid. This is useful
+ * when some properties (e.g. per-session `permissions`) should be
+ * inherited from a higher scope rather than materialized on every
+ * new session.
+ *
* Intended for sanitizing untrusted input at protocol boundaries
- * (e.g. `resolveSessionConfig`), where callers want a complete
- * type-safe object rather than a throw-on-first-error response.
- * Keys that fail validation are silently replaced with their
- * default; use {@link values} or {@link assertValid} when you want
- * a descriptive {@link ProtocolError} instead.
+ * (e.g. `resolveSessionConfig`). Keys that fail validation are
+ * silently replaced with their default or dropped; use
+ * {@link values} or {@link assertValid} when you want a descriptive
+ * {@link ProtocolError} instead.
*/
- validateOrDefault }>(values: Record | undefined, defaults: T): T;
+ validateOrDefault }>>(values: { [K in keyof T]?: unknown } | undefined, defaults: T): T;
}
export function createSchema(definition: D): ISchema {
@@ -147,14 +158,19 @@ export function createSchema(definition: D): ISchema
const narrowed: ISchemaProperty = prop;
narrowed.assertValid(value, key);
},
- validateOrDefault }>(values: Record | undefined, defaults: T): T {
+ validateOrDefault }>>(values: { [K in keyof T]?: unknown } | undefined, defaults: T): T {
const result: Record = {};
- for (const key of Object.keys(defaults)) {
- const raw = values?.[key];
+ const raw: { [K in keyof T]?: unknown } = values ?? {};
+ for (const key of Object.keys(definition)) {
const prop = definition[key];
- result[key] = prop && raw !== undefined && prop.validate(raw)
- ? raw
- : (defaults as Record)[key];
+ const candidate = raw[key];
+ if (candidate !== undefined && prop.validate(candidate)) {
+ result[key] = candidate;
+ } else if (Object.prototype.hasOwnProperty.call(defaults, key)) {
+ result[key] = (defaults as Record)[key];
+ }
+ // else: key not in defaults and incoming value missing/invalid
+ // → leave unset so higher-scope defaults can fill in.
}
return result as T;
},
@@ -249,6 +265,32 @@ export interface IPermissionsValue {
readonly deny: readonly string[];
}
+const permissionsProperty = schemaProperty({
+ type: 'object',
+ title: localize('agentHost.sessionConfig.permissions', "Permissions"),
+ description: localize('agentHost.sessionConfig.permissionsDescription', "Per-tool session permissions. Updated automatically when approving a tool \"in this Session\"."),
+ properties: {
+ allow: {
+ type: 'array',
+ title: localize('agentHost.sessionConfig.permissions.allow', "Allowed tools"),
+ items: {
+ type: 'string',
+ title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"),
+ },
+ },
+ deny: {
+ type: 'array',
+ title: localize('agentHost.sessionConfig.permissions.deny', "Denied tools"),
+ items: {
+ type: 'string',
+ title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"),
+ },
+ },
+ },
+ default: { allow: [], deny: [] },
+ sessionMutable: true,
+});
+
/**
* Session-config properties owned by the platform itself — i.e. consumed
* by the agent host rather than by any particular agent.
@@ -276,29 +318,19 @@ export const platformSessionSchema = createSchema({
default: 'default',
sessionMutable: true,
}),
- [SessionConfigKey.Permissions]: schemaProperty({
- type: 'object',
- title: localize('agentHost.sessionConfig.permissions', "Permissions"),
- description: localize('agentHost.sessionConfig.permissionsDescription', "Per-tool session permissions. Updated automatically when approving a tool \"in this Session\"."),
- properties: {
- allow: {
- type: 'array',
- title: localize('agentHost.sessionConfig.permissions.allow', "Allowed tools"),
- items: {
- type: 'string',
- title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"),
- },
- },
- deny: {
- type: 'array',
- title: localize('agentHost.sessionConfig.permissions.deny', "Denied tools"),
- items: {
- type: 'string',
- title: localize('agentHost.sessionConfig.permissions.toolName', "Tool name"),
- },
- },
- },
- default: { allow: [], deny: [] },
- sessionMutable: true,
- }),
+ [SessionConfigKey.Permissions]: permissionsProperty,
+});
+
+/**
+ * Root (agent host) config properties owned by the platform itself.
+ *
+ * Root config acts as the baseline that applies to every session:
+ *
+ * - {@link SessionConfigKey.Permissions} — host-wide allow/deny lists
+ * unioned with each session's own permissions when evaluating tool
+ * auto-approval. See `SessionPermissionManager` for the evaluation
+ * rules.
+ */
+export const platformRootSchema = createSchema({
+ [SessionConfigKey.Permissions]: permissionsProperty,
});
diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts
index 6643dbe30c974..015259b1a24ff 100644
--- a/src/vs/platform/agentHost/common/agentService.ts
+++ b/src/vs/platform/agentHost/common/agentService.ts
@@ -13,7 +13,7 @@ import type { ISyncedCustomization } from './agentPluginManager.js';
import type { IAgentSubscription } from './state/agentSubscription.js';
import type { CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from './state/protocol/commands.js';
import { ProtectedResourceMetadata, type ConfigSchema, type FileEdit, type ModelSelection, type SessionActiveClient, type ToolDefinition } from './state/protocol/state.js';
-import type { ActionEnvelope, INotification, SessionAction, TerminalAction } from './state/sessionActions.js';
+import type { ActionEnvelope, INotification, RootAction, SessionAction, TerminalAction } from './state/sessionActions.js';
import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceWriteParams, ResourceWriteResult, IStateSnapshot } from './state/sessionProtocol.js';
import { AttachmentType, ComponentToState, SessionInputResponseKind, SessionStatus, StateComponents, type CustomizationRef, type PendingMessage, type RootState, type SessionInputAnswer, type SessionInputRequest, type ToolCallResult, type ToolResultContent, type PolicyState, type StringOrMarkdown } from './state/sessionState.js';
@@ -615,7 +615,7 @@ export interface IAgentService {
* it to state, triggers side effects, and echoes it back via
* {@link onDidAction} with the client's origin for reconciliation.
*/
- dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void;
+ dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void;
/**
* List the contents of a directory on the agent host's filesystem.
@@ -668,7 +668,7 @@ export interface IAgentConnection {
getSubscriptionUnmanaged(kind: T, resource: URI): IAgentSubscription | undefined;
// ---- Action dispatch ----------------------------------------------------
- dispatch(action: SessionAction | TerminalAction): void;
+ dispatch(action: RootAction | SessionAction | TerminalAction): void;
// ---- Events (connection-level) ------------------------------------------
readonly onDidNotification: Event;
diff --git a/src/vs/platform/agentHost/common/state/agentSubscription.ts b/src/vs/platform/agentHost/common/state/agentSubscription.ts
index c36b92cd96320..26fd1e5be5e22 100644
--- a/src/vs/platform/agentHost/common/state/agentSubscription.ts
+++ b/src/vs/platform/agentHost/common/state/agentSubscription.ts
@@ -453,7 +453,7 @@ export class AgentSubscriptionManager extends Disposable {
* Dispatch a client action. Applies optimistically to the relevant
* subscription if applicable, then returns the clientSeq.
*/
- dispatchOptimistic(action: SessionAction | TerminalAction): number {
+ dispatchOptimistic(action: RootAction | SessionAction | TerminalAction): number {
if (isSessionAction(action)) {
const entry = this._subscriptions.get(URI.parse(action.session));
if (entry && entry.sub instanceof SessionStateSubscription) {
diff --git a/src/vs/platform/agentHost/electron-browser/agentHostService.ts b/src/vs/platform/agentHost/electron-browser/agentHostService.ts
index 44362189ea1ea..9d36a94fa9024 100644
--- a/src/vs/platform/agentHost/electron-browser/agentHostService.ts
+++ b/src/vs/platform/agentHost/electron-browser/agentHostService.ts
@@ -17,7 +17,7 @@ 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 { ActionEnvelope, INotification, SessionAction, TerminalAction } from '../common/state/sessionActions.js';
+import type { ActionEnvelope, INotification, RootAction, 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';
import { revive } from '../../../base/common/marshalling.js';
@@ -160,7 +160,7 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService {
unsubscribe(resource: URI): void {
this._proxy.unsubscribe(resource);
}
- dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void {
+ dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void {
this._proxy.dispatchAction(action, clientId, clientSeq);
}
private _nextSeq = 1;
@@ -180,7 +180,7 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService {
return this._subscriptionManager.getSubscriptionUnmanaged(resource);
}
- dispatch(action: SessionAction | TerminalAction): void {
+ dispatch(action: RootAction | SessionAction | TerminalAction): void {
const seq = this._subscriptionManager.dispatchOptimistic(action);
this.dispatchAction(action, this.clientId, seq);
}
diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts
index 92b5fb624c762..9bb890e45f401 100644
--- a/src/vs/platform/agentHost/node/agentHostStateManager.ts
+++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts
@@ -11,6 +11,8 @@ import { ActionType, NotificationType, ActionEnvelope, ActionOrigin, INotificati
import type { IStateSnapshot } from '../common/state/sessionProtocol.js';
import { rootReducer, sessionReducer } from '../common/state/sessionReducers.js';
import { createRootState, createSessionState, SessionLifecycle, type RootState, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI } from '../common/state/sessionState.js';
+import { IPermissionsValue, platformRootSchema } from '../common/agentHostSchema.js';
+import { SessionConfigKey } from '../common/sessionConfigKeys.js';
/**
* Server-side state manager for the sessions process protocol.
@@ -46,6 +48,19 @@ export class AgentHostStateManager extends Disposable {
) {
super();
this._rootState = createRootState();
+ // Seed the host-level configuration schema + default values so that
+ // RootConfigChanged actions can merge into it, and clients see the
+ // schema immediately upon subscribing to `agenthost:/root`. See
+ // `platformRootSchema` for the set of platform-owned properties.
+ this._rootState = {
+ ...this._rootState,
+ config: {
+ schema: platformRootSchema.toProtocol(),
+ values: platformRootSchema.validateOrDefault({}, {
+ [SessionConfigKey.Permissions]: { allow: [], deny: [] } satisfies IPermissionsValue,
+ }),
+ },
+ };
}
private readonly _log = (msg: string) => this._logService.warn(`[AgentHostStateManager] ${msg}`);
@@ -228,7 +243,7 @@ export class AgentHostStateManager extends Disposable {
* The action is applied to state and emitted with the client's origin
* so the originating client can reconcile.
*/
- dispatchClientAction(action: SessionAction | TerminalAction, origin: ActionOrigin): unknown {
+ dispatchClientAction(action: RootAction | SessionAction | TerminalAction, origin: ActionOrigin): unknown {
return this._applyAndEmit(action, origin);
}
diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts
index c6a4983c21afa..abf84f27c3cd5 100644
--- a/src/vs/platform/agentHost/node/agentService.ts
+++ b/src/vs/platform/agentHost/node/agentService.ts
@@ -16,7 +16,7 @@ import { ServiceCollection } from '../../instantiation/common/serviceCollection.
import { ILogService } from '../../log/common/log.js';
import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentMessageEvent, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent, AuthenticateParams, AuthenticateResult } from '../common/agentService.js';
import { ISessionDataService } from '../common/sessionDataService.js';
-import { ActionType, ActionEnvelope, INotification, SessionAction, TerminalAction, isSessionAction } from '../common/state/sessionActions.js';
+import { ActionType, ActionEnvelope, INotification, RootAction, SessionAction, TerminalAction, isSessionAction } from '../common/state/sessionActions.js';
import type { 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, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, parseSubagentSessionUri, type ResponsePart, type SessionConfigState, type ISessionFileDiff, type SessionSummary, type ToolCallCompletedState, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js';
@@ -415,7 +415,7 @@ export class AgentService extends Disposable implements IAgentService {
// in Phase 4 (multi-client). For now this is a no-op.
}
- dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void {
+ dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void {
this._logService.trace(`[AgentService] dispatchAction: type=${action.type}, clientId=${clientId}, clientSeq=${clientSeq}`, action);
const origin = { clientId, clientSeq };
diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts
index 7b2fdad114812..de102fdedde7f 100644
--- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts
+++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts
@@ -24,7 +24,7 @@ import { IInstantiationService } from '../../../instantiation/common/instantiati
import { ILogService } from '../../../log/common/log.js';
import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js';
import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentDeltaEvent, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js';
-import { AutoApproveLevel, IPermissionsValue, ISchemaProperty, createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js';
+import { AutoApproveLevel, ISchemaProperty, createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js';
import { SessionConfigKey } from '../../common/sessionConfigKeys.js';
import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js';
import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js';
@@ -620,7 +620,10 @@ export class CopilotAgent extends Disposable implements IAgent {
const values = sessionSchema.validateOrDefault(params.config, {
[SessionConfigKey.Isolation]: isolationValue,
[SessionConfigKey.AutoApprove]: 'default' satisfies AutoApproveLevel,
- [SessionConfigKey.Permissions]: { allow: [], deny: [] } satisfies IPermissionsValue,
+ // Permissions intentionally omitted — leave unset so auto-approval
+ // falls through to the host-level `permissions` default, and only
+ // materializes on the session once the user hits "Allow in this
+ // Session".
...(branchDefault !== undefined ? { [SessionConfigKey.Branch]: branchDefault } : {}),
});
diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts
index d01dd2de427d5..b0467bfb5c74a 100644
--- a/src/vs/platform/agentHost/node/protocolServerHandler.ts
+++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts
@@ -12,7 +12,7 @@ import { ILogService } from '../../log/common/log.js';
import { AHPFileSystemProvider } from '../common/agentHostFileSystemProvider.js';
import { AgentSession, type IAgentService } from '../common/agentService.js';
import type { CommandMap } from '../common/state/protocol/messages.js';
-import { ActionEnvelope, INotification, isSessionAction, isTerminalAction, type SessionAction } from '../common/state/sessionActions.js';
+import { ActionEnvelope, INotification, isSessionAction, isTerminalAction, type RootAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js';
import { MIN_PROTOCOL_VERSION, PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js';
import {
AHP_AUTH_REQUIRED,
@@ -181,7 +181,7 @@ export class ProtocolServerHandler extends Disposable {
case 'dispatchAction':
if (client) {
this._logService.trace(`[ProtocolServer] dispatchAction: ${JSON.stringify(msg.params.action.type)}`);
- const action = msg.params.action as SessionAction;
+ const action = msg.params.action as RootAction | SessionAction | TerminalAction;
this._agentService.dispatchAction(action, client.clientId, msg.params.clientSeq);
}
break;
diff --git a/src/vs/platform/agentHost/node/sessionPermissions.ts b/src/vs/platform/agentHost/node/sessionPermissions.ts
index eaf18711995dd..304fc5fa8b430 100644
--- a/src/vs/platform/agentHost/node/sessionPermissions.ts
+++ b/src/vs/platform/agentHost/node/sessionPermissions.ts
@@ -238,10 +238,13 @@ export class SessionPermissionManager extends Disposable {
if (!toolName) {
return false;
}
+ // `getEffectiveValue` walks session → parent → host, so sessions
+ // that haven't materialized their own `permissions` yet transparently
+ // inherit from the host-level allow/deny lists.
const permissions = this._configService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.Permissions);
const allowed = permissions?.allow.includes(toolName) ?? false;
if (allowed) {
- this._logService.trace(`[SessionPermissionManager] Auto-approving "${toolName}" via session permissions`);
+ this._logService.trace(`[SessionPermissionManager] Auto-approving "${toolName}" via permissions`);
}
return allowed;
}
diff --git a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts
index 8a3f806f1536e..eaee2c92cc130 100644
--- a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts
+++ b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts
@@ -254,9 +254,29 @@ suite('agentHostSchema', () => {
test('ignores keys not in defaults', () => {
const schema = fixture();
+ // @ts-expect-error: test that extra keys not in the defaults are ignored, even if they pass validation.
const result = schema.validateOrDefault({ name: 'a', count: 1, ignored: true }, { name: 'd', count: 0 });
assert.deepStrictEqual(result, { name: 'a', count: 1 });
});
+
+ test('omits schema keys that are missing from both values and defaults', () => {
+ // Regression coverage for the partial-defaults contract that
+ // underpins host-level inheritance: if the caller doesn't supply
+ // a default and no incoming value is valid, the key is left out
+ // entirely so higher-scope defaults can fill in.
+ const schema = fixture();
+ const result = schema.validateOrDefault({ count: 9 }, { count: 0 });
+ assert.deepStrictEqual(result, { count: 9 });
+ assert.ok(!result.hasOwnProperty('name'), '`name` should be absent when neither values nor defaults supply it');
+ });
+
+ test('omits schema keys when value is invalid and no default is supplied', () => {
+ const schema = fixture();
+ // @ts-expect-error: test that invalid values are dropped even when the caller doesn't provide a default.
+ const result = schema.validateOrDefault({ name: 42, count: 3 }, { count: 0 });
+ // `name` has no default and the incoming value is invalid → dropped.
+ assert.deepStrictEqual(result, { count: 3 });
+ });
});
// ---- platformSessionSchema sanity --------------------------------------
diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts
index cf5d8aab4c183..cdbdd9b7505d9 100644
--- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts
+++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts
@@ -61,7 +61,11 @@ suite('AgentHostStateManager', () => {
const snapshot = manager.getSnapshot(ROOT_STATE_URI);
assert.ok(snapshot);
assert.strictEqual(snapshot.resource.toString(), ROOT_STATE_URI.toString());
- assert.deepStrictEqual(snapshot.state, { agents: [], activeSessions: 0 });
+ const root = snapshot.state as { agents: unknown[]; activeSessions: number; config?: { values?: Record } };
+ assert.deepStrictEqual(root.agents, []);
+ assert.strictEqual(root.activeSessions, 0);
+ // Host config is seeded with the platform root schema and defaults.
+ assert.ok(root.config, 'root state should include a seeded config');
});
test('getSnapshot returns session snapshot after creation', () => {
@@ -180,7 +184,9 @@ suite('AgentHostStateManager', () => {
test('root state starts with activeSessions: 0', () => {
const snapshot = manager.getSnapshot(ROOT_STATE_URI);
assert.ok(snapshot);
- assert.deepStrictEqual(snapshot.state, { agents: [], activeSessions: 0 });
+ const root = snapshot.state as { agents: unknown[]; activeSessions: number };
+ assert.deepStrictEqual(root.agents, []);
+ assert.strictEqual(root.activeSessions, 0);
});
test('turnStarted dispatches root/activeSessionsChanged with correct count', () => {
diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts
index 191e01f71539e..95e80e6df76b5 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c
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 { ActionType, type SessionAction } from '../../common/state/sessionActions.js';
+import { ActionType, type RootAction, type SessionAction, type TerminalAction } from '../../common/state/sessionActions.js';
import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js';
import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js';
import { SessionStatus, type SessionSummary } from '../../common/state/sessionState.js';
@@ -68,7 +68,7 @@ class MockProtocolServer implements IProtocolServer {
class MockAgentService implements IAgentService {
declare readonly _serviceBrand: undefined;
- readonly handledActions: SessionAction[] = [];
+ readonly handledActions: (RootAction | SessionAction | TerminalAction)[] = [];
readonly browsedUris: URI[] = [];
readonly browseErrors = new Map();
readonly listedSessions: IAgentSessionMetadata[] = [];
@@ -86,7 +86,7 @@ class MockAgentService implements IAgentService {
this._stateManager = sm;
}
- dispatchAction(action: SessionAction, clientId: string, clientSeq: number): void {
+ dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void {
this.handledActions.push(action);
const origin = { clientId, clientSeq };
this._stateManager.dispatchClientAction(action, origin);
diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts
index 28bbcd85e94a4..43ee26dc2f128 100644
--- a/src/vs/platform/browserView/common/browserView.ts
+++ b/src/vs/platform/browserView/common/browserView.ts
@@ -13,6 +13,7 @@ export enum BrowserViewCommandId {
Open = `${commandPrefix}.open`,
NewTab = `${commandPrefix}.newTab`,
QuickOpen = `${commandPrefix}.quickOpen`,
+ OpenOrList = `${commandPrefix}.openOrList`,
CloseAll = `${commandPrefix}.closeAll`,
CloseAllInGroup = `${commandPrefix}.closeAllInGroup`,
diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts
index 548dbac4bf645..8926bc648039a 100644
--- a/src/vs/platform/browserView/electron-main/browserView.ts
+++ b/src/vs/platform/browserView/electron-main/browserView.ts
@@ -115,7 +115,7 @@ export class BrowserView extends Disposable {
});
// Use a default size of 1024x768.
- this._view.setBounds({ x: 0, y: 0, width: 1024, height: 768 });
+ this._view.setBounds({ x: -10000, y: -10000, width: 1024, height: 768 });
this._view.setBackgroundColor('#FFFFFF');
this._ownerWindow = this.windowsMainService.getWindowById(owner.mainWindowId)!;
@@ -125,7 +125,7 @@ export class BrowserView extends Disposable {
this._register(this._ownerWindow.onDidClose(() => this.dispose()));
this._view.setVisible(false);
- this._ownerWindow.win?.contentView.addChildView(this._view, 0);
+ this._ownerWindow.win?.contentView.addChildView(this._view);
this._view.webContents.setWindowOpenHandler((details) => {
const location = (() => {
@@ -592,9 +592,11 @@ export class BrowserView extends Disposable {
* Capture a screenshot of this view
*/
async captureScreenshot(options?: IBrowserViewCaptureScreenshotOptions): Promise {
- // This ensures the webContents rendering pipeline is ready so background tabs can be captured too.
- this._view.setVisible(true);
- this._view.setVisible(false);
+ if (!this._view.getVisible()) {
+ // This ensures the webContents rendering pipeline is ready so background tabs can be captured too.
+ this._view.setVisible(true);
+ this._view.setVisible(false);
+ }
const quality = options?.quality ?? 80;
if (options?.pageRect) {
diff --git a/src/vs/platform/environment/common/environmentService.ts b/src/vs/platform/environment/common/environmentService.ts
index 1d7dfff7d1723..004d0614c938a 100644
--- a/src/vs/platform/environment/common/environmentService.ts
+++ b/src/vs/platform/environment/common/environmentService.ts
@@ -162,26 +162,6 @@ export abstract class AbstractNativeEnvironmentService implements INativeEnviron
return joinPath(this.userHome, this.productService.sharedDataFolderName);
}
- @memoize
- get agentPluginsPath(): string {
- const cliAgentPluginsDir = this.args['agent-plugins-dir'];
- if (cliAgentPluginsDir) {
- return resolve(cliAgentPluginsDir);
- }
-
- const vscodeAgentPlugins = env['VSCODE_AGENT_PLUGINS'];
- if (vscodeAgentPlugins) {
- return vscodeAgentPlugins;
- }
-
- const vscodePortable = env['VSCODE_PORTABLE'];
- if (vscodePortable) {
- return join(vscodePortable, 'agent-plugins');
- }
-
- return joinPath(this.userHome, this.productService.dataFolderName, 'agent-plugins').fsPath;
- }
-
@memoize
get extensionDevelopmentLocationURI(): URI[] | undefined {
const extensionDevelopmentPaths = this.args.extensionDevelopmentPath;
diff --git a/src/vs/platform/sandbox/common/terminalSandboxService.ts b/src/vs/platform/sandbox/common/terminalSandboxService.ts
index f790fbdb1fdb0..ebaa2bd9d225f 100644
--- a/src/vs/platform/sandbox/common/terminalSandboxService.ts
+++ b/src/vs/platform/sandbox/common/terminalSandboxService.ts
@@ -73,7 +73,7 @@ export interface ITerminalSandboxService {
isEnabled(): Promise;
getOS(): Promise;
checkForSandboxingPrereqs(forceRefresh?: boolean): Promise;
- wrapCommand(command: string, requestUnsandboxedExecution?: boolean, shell?: string): ITerminalSandboxWrapResult;
+ wrapCommand(command: string, requestUnsandboxedExecution?: boolean, shell?: string, commandKeywords?: readonly string[], cwd?: URI): Promise;
getSandboxConfigPath(forceRefresh?: boolean): Promise;
getTempDir(): URI | undefined;
setNeedsForceUpdateConfigFile(): void;
@@ -97,7 +97,7 @@ export class NullTerminalSandboxService implements ITerminalSandboxService {
return { enabled: false, sandboxConfigPath: undefined, failedCheck: undefined };
}
- wrapCommand(command: string): ITerminalSandboxWrapResult {
+ async wrapCommand(command: string): Promise {
return { command, isSandboxWrapped: false };
}
diff --git a/src/vs/platform/storage/test/electron-main/storageMainService.test.ts b/src/vs/platform/storage/test/electron-main/storageMainService.test.ts
index 7c54f44d5c914..eef84a5d3517a 100644
--- a/src/vs/platform/storage/test/electron-main/storageMainService.test.ts
+++ b/src/vs/platform/storage/test/electron-main/storageMainService.test.ts
@@ -63,6 +63,7 @@ suite('StorageMainService', function () {
promptsHome: joinPath(inMemoryProfileRoot, 'promptsHome'),
extensionsResource: joinPath(inMemoryProfileRoot, 'extensionsResource'),
cacheHome: joinPath(inMemoryProfileRoot, 'cache'),
+ agentPluginsHome: joinPath(inMemoryProfileRoot, 'agentPluginsHome'),
};
class TestStorageMainService extends StorageMainService {
@@ -131,7 +132,7 @@ suite('StorageMainService', function () {
const environmentService = new NativeEnvironmentService(parseArgs(process.argv, OPTIONS), productService);
const fileService = disposables.add(new FileService(new NullLogService()));
const uriIdentityService = disposables.add(new UriIdentityService(fileService));
- const testStorageService = disposables.add(new TestStorageMainService(new NullLogService(), environmentService, disposables.add(new UserDataProfilesMainService(disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, new NullLogService(), fileService)), disposables.add(uriIdentityService), environmentService, fileService, new NullLogService())), lifecycleMainService, fileService, uriIdentityService, nullCrossAppIPCService));
+ const testStorageService = disposables.add(new TestStorageMainService(new NullLogService(), environmentService, disposables.add(new UserDataProfilesMainService(disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, new NullLogService(), fileService)), disposables.add(uriIdentityService), environmentService, fileService, new NullLogService(), productService)), lifecycleMainService, fileService, uriIdentityService, nullCrossAppIPCService));
disposables.add(testStorageService.applicationStorage);
@@ -300,7 +301,7 @@ suite('StorageMainService', function () {
const environmentService = new NativeEnvironmentService(parseArgs(process.argv, OPTIONS), productService);
const fileService = disposables.add(new FileService(new NullLogService()));
const uriIdentityService = disposables.add(new UriIdentityService(fileService));
- const storageMainService = disposables.add(new TestStorageMainService(new NullLogService(), environmentService, disposables.add(new UserDataProfilesMainService(disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, new NullLogService(), fileService)), disposables.add(uriIdentityService), environmentService, fileService, new NullLogService())), new TestLifecycleMainService(), fileService, uriIdentityService, crossAppIPCService));
+ const storageMainService = disposables.add(new TestStorageMainService(new NullLogService(), environmentService, disposables.add(new UserDataProfilesMainService(disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, new NullLogService(), fileService)), disposables.add(uriIdentityService), environmentService, fileService, new NullLogService(), productService)), new TestLifecycleMainService(), fileService, uriIdentityService, crossAppIPCService));
const storage = storageMainService.applicationSharedStorage;
disposables.add(storage);
@@ -336,7 +337,7 @@ suite('StorageMainService', function () {
onDidReceiveMessage: onDidReceiveMessage2.event,
};
- const storageMainService2 = disposables.add(new TestStorageMainService(new NullLogService(), environmentService, disposables.add(new UserDataProfilesMainService(disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, new NullLogService(), fileService)), disposables.add(new UriIdentityService(fileService)), environmentService, fileService, new NullLogService())), new TestLifecycleMainService(), fileService, disposables.add(new UriIdentityService(fileService)), crossAppIPCService2));
+ const storageMainService2 = disposables.add(new TestStorageMainService(new NullLogService(), environmentService, disposables.add(new UserDataProfilesMainService(disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, new NullLogService(), fileService)), disposables.add(new UriIdentityService(fileService)), environmentService, fileService, new NullLogService(), productService)), new TestLifecycleMainService(), fileService, disposables.add(new UriIdentityService(fileService)), crossAppIPCService2));
const storage2 = storageMainService2.applicationSharedStorage;
disposables.add(storage2);
diff --git a/src/vs/platform/userDataProfile/common/userDataProfile.ts b/src/vs/platform/userDataProfile/common/userDataProfile.ts
index b2443cded4853..fb2e942ca2bf5 100644
--- a/src/vs/platform/userDataProfile/common/userDataProfile.ts
+++ b/src/vs/platform/userDataProfile/common/userDataProfile.ts
@@ -54,6 +54,7 @@ export interface IUserDataProfile {
readonly promptsHome: URI;
readonly extensionsResource: URI;
readonly mcpResource: URI;
+ readonly agentPluginsHome: URI;
readonly cacheHome: URI;
readonly useDefaultFlags?: UseDefaultProfileFlags;
readonly isTransient?: boolean;
@@ -76,6 +77,7 @@ export function isUserDataProfile(thing: unknown): thing is IUserDataProfile {
&& URI.isUri(candidate.promptsHome)
&& URI.isUri(candidate.extensionsResource)
&& URI.isUri(candidate.mcpResource)
+ && URI.isUri(candidate.agentPluginsHome)
);
}
@@ -154,6 +156,7 @@ export function reviveProfile(profile: UriDto, scheme: string)
promptsHome: URI.revive(profile.promptsHome).with({ scheme }),
extensionsResource: URI.revive(profile.extensionsResource).with({ scheme }),
mcpResource: URI.revive(profile.mcpResource).with({ scheme }),
+ agentPluginsHome: URI.revive(profile.agentPluginsHome),
cacheHome: URI.revive(profile.cacheHome).with({ scheme }),
useDefaultFlags: profile.useDefaultFlags,
isTransient: profile.isTransient,
@@ -176,6 +179,7 @@ export function toUserDataProfile(id: string, name: string, location: URI, profi
promptsHome: defaultProfile && options?.useDefaultFlags?.prompts ? defaultProfile.promptsHome : joinPath(location, 'prompts'),
extensionsResource: defaultProfile && options?.useDefaultFlags?.extensions ? defaultProfile.extensionsResource : joinPath(location, 'extensions.json'),
mcpResource: defaultProfile && options?.useDefaultFlags?.mcp ? defaultProfile.mcpResource : joinPath(location, 'mcp.json'),
+ agentPluginsHome: defaultProfile ? defaultProfile.agentPluginsHome : joinPath(location, 'agent-plugins'),
cacheHome: joinPath(profilesCacheHome, id),
useDefaultFlags: options?.useDefaultFlags,
isTransient: options?.transient,
diff --git a/src/vs/platform/userDataProfile/electron-main/userDataProfile.ts b/src/vs/platform/userDataProfile/electron-main/userDataProfile.ts
index e6510943dca88..19842b144ad0a 100644
--- a/src/vs/platform/userDataProfile/electron-main/userDataProfile.ts
+++ b/src/vs/platform/userDataProfile/electron-main/userDataProfile.ts
@@ -10,11 +10,16 @@ import { INativeEnvironmentService } from '../../environment/common/environment.
import { IFileService } from '../../files/common/files.js';
import { refineServiceDecorator } from '../../instantiation/common/instantiation.js';
import { ILogService } from '../../log/common/log.js';
+import { IProductService } from '../../product/common/productService.js';
import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js';
import { IUserDataProfilesService, WillCreateProfileEvent, WillRemoveProfileEvent, IUserDataProfile } from '../common/userDataProfile.js';
import { UserDataProfilesService } from '../node/userDataProfile.js';
import { IAnyWorkspaceIdentifier, IEmptyWorkspaceIdentifier } from '../../workspace/common/workspace.js';
import { IStateService } from '../../state/node/state.js';
+import { URI } from '../../../base/common/uri.js';
+import { NativeParsedArgs } from '../../environment/common/argv.js';
+import { env } from '../../../base/common/process.js';
+import { join, resolve } from '../../../base/common/path.js';
export const IUserDataProfilesMainService = refineServiceDecorator(IUserDataProfilesService);
export interface IUserDataProfilesMainService extends IUserDataProfilesService {
@@ -27,18 +32,25 @@ export interface IUserDataProfilesMainService extends IUserDataProfilesService {
export class UserDataProfilesMainService extends UserDataProfilesService implements IUserDataProfilesMainService {
+ private readonly agentPluginsHome: URI;
+
constructor(
@IStateService stateService: IStateService,
@IUriIdentityService uriIdentityService: IUriIdentityService,
@INativeEnvironmentService environmentService: INativeEnvironmentService,
@IFileService fileService: IFileService,
@ILogService logService: ILogService,
+ @IProductService private readonly productService: IProductService,
) {
super(stateService, uriIdentityService, environmentService, fileService, logService);
+ this.agentPluginsHome = URI.file(getAgentPluginsPath(environmentService.args, environmentService.userHome, productService.dataFolderName));
}
protected override createDefaultProfile(): IUserDataProfile {
- const defaultProfile = super.createDefaultProfile();
+ const defaultProfile = {
+ ...super.createDefaultProfile(),
+ agentPluginsHome: this.agentPluginsHome
+ };
if (!(process as INodeProcess).isEmbeddedApp) {
return defaultProfile;
}
@@ -46,11 +58,13 @@ export class UserDataProfilesMainService extends UserDataProfilesService impleme
if (!hostUserRoamingDataHome) {
return defaultProfile;
}
+ const hostAgentPluginsHome = getHostAgentPluginsPath(this.nativeEnvironmentService, this.productService);
return {
...defaultProfile,
keybindingsResource: joinPath(hostUserRoamingDataHome, 'keybindings.json'),
promptsHome: joinPath(hostUserRoamingDataHome, 'prompts'),
mcpResource: joinPath(hostUserRoamingDataHome, 'mcp.json'),
+ agentPluginsHome: hostAgentPluginsHome ? URI.file(hostAgentPluginsHome) : this.agentPluginsHome
};
}
@@ -61,5 +75,46 @@ export class UserDataProfilesMainService extends UserDataProfilesService impleme
}
return emptyWindows;
}
+}
+
+function getHostAgentPluginsPath(environmentService: INativeEnvironmentService, productService: IProductService): string | undefined {
+ if (!(process as INodeProcess).isEmbeddedApp) {
+ return undefined;
+ }
+ if (!environmentService.isBuilt) {
+ return undefined;
+ }
+
+ const quality = productService.quality;
+ let hostDataFolderName: string;
+ if (quality === 'stable') {
+ hostDataFolderName = '.vscode';
+ } else if (quality === 'insider') {
+ hostDataFolderName = '.vscode-insiders';
+ } else if (quality === 'exploration') {
+ hostDataFolderName = '.vscode-exploration';
+ } else {
+ return undefined;
+ }
+
+ return getAgentPluginsPath(environmentService.args, environmentService.userHome, hostDataFolderName);
+}
+
+function getAgentPluginsPath(args: NativeParsedArgs, userHome: URI, dataFolderName: string): string {
+ const cliAgentPluginsDir = args['agent-plugins-dir'];
+ if (cliAgentPluginsDir) {
+ return resolve(cliAgentPluginsDir);
+ }
+
+ const vscodeAgentPlugins = env['VSCODE_AGENT_PLUGINS'];
+ if (vscodeAgentPlugins) {
+ return vscodeAgentPlugins;
+ }
+
+ const vscodePortable = env['VSCODE_PORTABLE'];
+ if (vscodePortable) {
+ return join(vscodePortable, 'agent-plugins');
+ }
+ return joinPath(userHome, dataFolderName, 'agent-plugins').fsPath;
}
diff --git a/src/vs/platform/userDataProfile/node/userDataProfile.ts b/src/vs/platform/userDataProfile/node/userDataProfile.ts
index 664b85109e97e..a992766f60e34 100644
--- a/src/vs/platform/userDataProfile/node/userDataProfile.ts
+++ b/src/vs/platform/userDataProfile/node/userDataProfile.ts
@@ -20,7 +20,7 @@ export class UserDataProfilesReadonlyService extends BaseUserDataProfilesService
constructor(
@IStateReadService private readonly stateReadonlyService: IStateReadService,
@IUriIdentityService uriIdentityService: IUriIdentityService,
- @INativeEnvironmentService private readonly nativeEnvironmentService: INativeEnvironmentService,
+ @INativeEnvironmentService protected readonly nativeEnvironmentService: INativeEnvironmentService,
@IFileService fileService: IFileService,
@ILogService logService: ILogService,
) {
diff --git a/src/vs/platform/userDataProfile/test/electron-main/userDataProfileMainService.test.ts b/src/vs/platform/userDataProfile/test/electron-main/userDataProfileMainService.test.ts
index ce4bf69348300..24b59d16d8d68 100644
--- a/src/vs/platform/userDataProfile/test/electron-main/userDataProfileMainService.test.ts
+++ b/src/vs/platform/userDataProfile/test/electron-main/userDataProfileMainService.test.ts
@@ -15,6 +15,7 @@ import product from '../../../product/common/product.js';
import { UserDataProfilesMainService } from '../../electron-main/userDataProfile.js';
import { SaveStrategy, StateService } from '../../../state/node/stateService.js';
import { UriIdentityService } from '../../../uriIdentity/common/uriIdentityService.js';
+import { IProductService } from '../../../product/common/productService.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
const ROOT = URI.file('tests').with({ scheme: 'vscode-tests' });
@@ -23,6 +24,7 @@ class TestEnvironmentService extends AbstractNativeEnvironmentService {
constructor(private readonly _appSettingsHome: URI) {
super(Object.create(null), Object.create(null), { _serviceBrand: undefined, ...product });
}
+ override get userHome() { return this._appSettingsHome; }
override get userRoamingDataHome() { return this._appSettingsHome.with({ scheme: Schemas.vscodeUserData }); }
override get extensionsPath() { return joinPath(this.userRoamingDataHome, 'extensions.json').path; }
override get stateResource() { return joinPath(this.userRoamingDataHome, 'state.json'); }
@@ -44,7 +46,8 @@ suite('UserDataProfileMainService', () => {
environmentService = new TestEnvironmentService(joinPath(ROOT, 'User'));
stateService = disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, logService, fileService));
- testObject = disposables.add(new UserDataProfilesMainService(stateService, disposables.add(new UriIdentityService(fileService)), environmentService, fileService, logService));
+ const productService: IProductService = { _serviceBrand: undefined, ...product };
+ testObject = disposables.add(new UserDataProfilesMainService(stateService, disposables.add(new UriIdentityService(fileService)), environmentService, fileService, logService, productService));
await stateService.init();
});
diff --git a/src/vs/platform/workspaces/test/electron-main/workspacesManagementMainService.test.ts b/src/vs/platform/workspaces/test/electron-main/workspacesManagementMainService.test.ts
index 8b870f33a2735..5c68f94a6df19 100644
--- a/src/vs/platform/workspaces/test/electron-main/workspacesManagementMainService.test.ts
+++ b/src/vs/platform/workspaces/test/electron-main/workspacesManagementMainService.test.ts
@@ -110,7 +110,7 @@ flakySuite('WorkspacesManagementMainService', () => {
const logService = new NullLogService();
const fileService = new FileService(logService);
- service = new WorkspacesManagementMainService(environmentMainService, logService, new UserDataProfilesMainService(new StateService(SaveStrategy.DELAYED, environmentMainService, logService, fileService), new UriIdentityService(fileService), environmentMainService, fileService, logService), new TestBackupMainService(), new TestDialogMainService());
+ service = new WorkspacesManagementMainService(environmentMainService, logService, new UserDataProfilesMainService(new StateService(SaveStrategy.DELAYED, environmentMainService, logService, fileService), new UriIdentityService(fileService), environmentMainService, fileService, logService, productService), new TestBackupMainService(), new TestDialogMainService());
return fs.promises.mkdir(untitledWorkspacesHomePath, { recursive: true });
});
diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md
index 5dc82ac562e2a..f691d6641ea1c 100644
--- a/src/vs/sessions/AI_CUSTOMIZATIONS.md
+++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md
@@ -51,7 +51,7 @@ Sessions-specific overrides:
```
src/vs/sessions/contrib/chat/browser/
├── aiCustomizationWorkspaceService.ts # Sessions workspace service override
-├── customizationHarnessService.ts # Sessions harness service (CLI harness only)
+├── customizationHarnessService.ts # Sessions harness service (accepts any content-provider-backed session type)
└── promptsService.ts # AgenticPromptsService (CLI user roots)
src/vs/sessions/contrib/sessions/browser/
├── aiCustomizationShortcutsWidget.ts # Shortcuts widget
@@ -92,7 +92,7 @@ Available harnesses:
| `claude` | Claude | Restricts user roots to `~/.claude`; hides Prompts + Plugins sections |
In core VS Code, all three harnesses are registered but CLI and Claude only appear when their respective agents are registered (`requiredAgentId` checked via `IChatAgentService`). VS Code is the default.
-In sessions, only CLI is registered (single harness, toggle bar hidden).
+In sessions, harnesses are accepted for any session type that has a registered content provider (checked via `IChatSessionsService.getContentProviderSchemes()`). AHP remote servers register directly via `registerExternalHarness`.
### IHarnessDescriptor
@@ -220,7 +220,7 @@ Skills that are directly invoked by UI elements (toolbar buttons, menu items) ar
### Count Consistency
-`customizationCounts.ts` uses the **same data sources** as the list widget. Both go through the active harness's `ICustomizationItemProvider` (or the `PromptsServiceCustomizationItemProvider` fallback), ensuring counts match what the list displays.
+`customizationCounts.ts` uses the **same data sources** as the list widget. When a harness with an `itemProvider` is active (determined by `getActiveItemProvider()`), counts come from that provider's `provideChatSessionCustomizations()`. Otherwise, both counts and the list go through the `PromptsServiceCustomizationItemProvider` fallback, ensuring counts match what the list displays.
### Item Badges
diff --git a/src/vs/sessions/MOBILE.md b/src/vs/sessions/MOBILE.md
index 7b18d586d8c66..7b3181e22bf38 100644
--- a/src/vs/sessions/MOBILE.md
+++ b/src/vs/sessions/MOBILE.md
@@ -13,7 +13,7 @@ Desktop Parts (`ChatBarPart`, `SidebarPart`, `PanelPart`, `AuxiliaryBarPart`) re
Each mobile Part checks the current layout class (via `isPhoneLayout(layoutService)`) at every call. When the viewport is phone it applies mobile behavior (full-cell layout, no card chrome, no session-bar subtraction). When the viewport is tablet/desktop — which happens when a real phone rotates past the 640px breakpoint — it delegates to the desktop `super` implementation. This means a `Mobile*Part` instance is safe to keep through a viewport-class transition without producing wrong layout math.
This means:
-- Desktop code has **zero** phone-layout checks — all mobile logic lives in mobile subclasses, `MobileTopBar`, and CSS.
+- Desktop code has **zero** phone-layout checks — all mobile logic lives in mobile subclasses, `MobileTitlebarPart`, and CSS.
- Phone-instantiated parts adapt correctly to rotation across the 640px breakpoint by delegating to `super`.
After a viewport-class transition the workbench calls `updateStyles()` on each pane composite part so card-chrome inline styles get re-applied (desktop) or cleared (phone) for the new class.
@@ -43,7 +43,7 @@ Two registrations can target the same slot with opposite `when` clauses, pointin
| "Open in VS Code" action | ❌ Gated | `when: !sessionsIsPhoneLayout` on menu item |
| Code review toolbar | ❌ Gated | `when: !sessionsIsPhoneLayout` on menu item |
| Customizations toolbar | ❌ Hidden | CSS `display: none` on phone |
-| Titlebar | ❌ Hidden | Grid `visible: false` + CSS + MobileTopBar replacement |
+| Titlebar | ❌ Hidden | Grid `visible: false` + CSS + MobileTitlebarPart replacement |
### Phone Layout
@@ -51,7 +51,7 @@ On phone-sized viewports (`< 640px` width):
```
┌──────────────────────────────────┐
-│ [☰] Session Title [+] │ ← MobileTopBar (prepended before grid)
+│ [☰] Session Title [+] │ ← MobileTitlebarPart (prepended before grid)
├──────────────────────────────────┤
│ │
│ Chat (edge-to-edge) │ ← Grid: ChatBarPart fills 100%
@@ -64,9 +64,9 @@ On phone-sized viewports (`< 640px` width):
└──────────────────────────────────┘
```
-- **MobileTopBar** is a DOM element prepended above the grid. It has a hamburger (☰), session title, and new session (+) button.
+- **MobileTitlebarPart** is a DOM element prepended above the grid. It has a hamburger (☰), session title, and new session (+) button.
- **Sidebar** is hidden by default and opens as an **85% width drawer overlay** with a backdrop when the hamburger is tapped. CSS makes its `split-view-view` absolutely positioned with `z-index: 250`. The workbench manually calls `sidebarPart.layout()` with drawer dimensions after opening. Closing the drawer clears the navigation stack.
-- **Titlebar** is hidden in the grid (`visible: false`) and via CSS — replaced by MobileTopBar.
+- **Titlebar** is hidden in the grid (`visible: false`) and via CSS — replaced by MobileTitlebarPart.
- **SessionCompositeBar** (chat tabs) is hidden via CSS.
- The grid uses `display: flex; flex-direction: column` and all `split-view-view:has(> .part)` containers are positioned absolutely at `100% width/height`.
@@ -77,7 +77,7 @@ On phone-sized viewports (`< 640px` width):
- **tablet**: `640px ≤ width < 1024px` (treated as desktop; no phone-specific chrome)
- **desktop**: `width ≥ 1024px`
-The workbench toggles the `phone-layout` CSS class on `layout()` and creates/destroys mobile components when the viewport class changes at runtime (e.g., DevTools device emulation, or a real phone rotating across the 640px breakpoint). MobileTopBar lifecycle is managed via a `DisposableStore` that is cleared on viewport transitions to prevent leaks.
+The workbench toggles the `phone-layout` CSS class on `layout()` and creates/destroys mobile components when the viewport class changes at runtime (e.g., DevTools device emulation, or a real phone rotating across the 640px breakpoint). MobileTitlebarPart lifecycle is managed via a `DisposableStore` that is cleared on viewport transitions to prevent leaks.
### Context Keys
@@ -90,13 +90,13 @@ The workbench toggles the `phone-layout` CSS class on `layout()` and creates/des
| Desktop Component | Mobile Equivalent | How Accessed |
|---|---|---|
-| **Titlebar** (3-section toolbar) | **MobileTopBar** (☰ / title / +) | Always visible at top |
+| **Titlebar** (3-section toolbar) | **MobileTitlebarPart** (☰ / title / +) | Always visible at top |
| **Sidebar** (sessions list) | Drawer overlay (85% width) | Hamburger button (☰) |
| **ChatBar** (chat widget) | Same Part, edge-to-edge, no card chrome | Default view (always visible) |
| **AuxiliaryBar** (files, changes) | Gated — not shown on mobile | Planned: mobile-specific view |
| **Panel** (terminal, output) | Gated — not shown on mobile | Planned: mobile-specific view |
| **SessionCompositeBar** (chat tabs) | Hidden on phone | — |
-| **New Session** (sidebar button) | + button in MobileTopBar | Always visible in top bar |
+| **New Session** (sidebar button) | + button in MobileTitlebarPart | Always visible in top bar |
## File Map
@@ -113,7 +113,7 @@ The workbench toggles the `phone-layout` CSS class on `layout()` and creates/des
| File | Purpose |
|------|---------|
-| `browser/parts/mobile/mobileTopBar.ts` | Phone top bar: hamburger (☰), session title, new session (+). Emits `onDidClickHamburger`, `onDidClickNewSession`, `onDidClickTitle`. |
+| `browser/parts/mobile/mobileTitlebarPart.ts` | Phone top bar: hamburger (☰), session title, new session (+). Emits `onDidClickHamburger`, `onDidClickNewSession`, `onDidClickTitle`. |
| `browser/parts/mobile/mobileChatShell.css` | **Single source of truth** for all phone-layout CSS: flex column layout, split-view-view absolute positioning, card chrome removal, part/content width overrides, sidebar title hiding, composite bar hiding, welcome page layout, sash hiding, button focus overrides, mobile pickers. |
### Layout & Navigation
@@ -134,7 +134,7 @@ The workbench toggles the `phone-layout` CSS class on `layout()` and creates/des
| File | Key Changes |
|------|-------------|
-| `browser/workbench.ts` | Layout policy integration, MobileTopBar creation/destruction (via `DisposableStore`), sidebar drawer open/close with backdrop, viewport-class-change detection, window resize listener, grid height calculation (subtracts MobileTopBar height), titlebar grid visibility toggle, `ISessionsManagementService` for new session button. |
+| `browser/workbench.ts` | Layout policy integration, MobileTitlebarPart creation/destruction (via `DisposableStore`), sidebar drawer open/close with backdrop, viewport-class-change detection, window resize listener, grid height calculation (subtracts MobileTitlebarPart height), titlebar grid visibility toggle, `ISessionsManagementService` for new session button. |
| `browser/parts/chatBarPart.ts` | `_lastLayout` changed from `private` to `protected` for mobile subclass access. |
### Styling
@@ -147,7 +147,6 @@ The workbench toggles the `phone-layout` CSS class on `layout()` and creates/des
## Remaining Work
-- **Session title sync**: MobileTopBar shows hardcoded "New Session" — needs to subscribe to `sessionsManagementService.activeSession` and update title when session changes.
- **Files & Terminal access**: Should become phone-specific views gated with `when: IsPhoneLayoutContext`.
- **iOS keyboard handling**: Adjust layout when virtual keyboard appears (context key exists, but no layout response yet).
- **Session list inline actions**: Make always-visible on touch devices (no hover-to-reveal).
diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts
index c81d9ea0704eb..3e449ca83e173 100644
--- a/src/vs/sessions/browser/menus.ts
+++ b/src/vs/sessions/browser/menus.ts
@@ -17,6 +17,7 @@ export const Menus = {
TitleBarSessionTitle: new MenuId('SessionsTitleBarSessionTitle'),
TitleBarSessionMenu: new MenuId('SessionsTitleBarSessionMenu'),
TitleBarRightLayout: new MenuId('SessionsTitleBarRightLayout'),
+ MobileTitleBarCenter: new MenuId('SessionsMobileTitleBarCenter'),
PanelTitle: new MenuId('SessionsPanelTitle'),
SidebarTitle: new MenuId('SessionsSidebarTitle'),
SidebarSessionsHeader: new MenuId('SessionsSidebarSessionsHeader'),
diff --git a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css
index 9e65c868ed321..72eddfcfe05a4 100644
--- a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css
+++ b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css
@@ -51,6 +51,14 @@
background: var(--vscode-toolbar-hoverBackground);
}
+.mobile-top-bar .mobile-top-bar-center {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ height: 100%;
+}
+
.mobile-top-bar .mobile-session-title {
flex: 1;
min-width: 0;
@@ -63,12 +71,45 @@
white-space: nowrap;
padding: 0 4px;
cursor: pointer;
+ border: none;
+ background: none;
+ touch-action: manipulation;
+ font-family: inherit;
+}
+
+.monaco-workbench .mobile-top-bar .mobile-session-title:focus {
+ outline: none !important;
+}
+
+.mobile-top-bar .mobile-session-title:focus-visible {
+ outline: 1px solid var(--vscode-focusBorder);
+ outline-offset: -1px;
}
.mobile-top-bar .mobile-session-title:active {
opacity: 0.7;
}
+.mobile-top-bar .mobile-top-bar-actions {
+ display: none;
+ flex: 1;
+ min-width: 0;
+ align-items: center;
+ height: 100%;
+}
+
+/* When the welcome screen is visible and the center menu has contributed
+ items (e.g., the web host filter on the home screen) the title is
+ hidden and the toolbar takes its place. */
+.mobile-top-bar.show-actions .mobile-session-title {
+ display: none;
+}
+
+.mobile-top-bar.show-actions .mobile-top-bar-actions {
+ display: flex;
+ flex-direction: column;
+}
+
/* ---- Phone Layout: Full-screen chat ---- */
/* On phone, stack the mobile top bar and grid vertically */
diff --git a/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts b/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts
new file mode 100644
index 0000000000000..550ac0944a315
--- /dev/null
+++ b/src/vs/sessions/browser/parts/mobile/mobileTitlebarPart.ts
@@ -0,0 +1,141 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import './mobileChatShell.css';
+import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js';
+import { $, addDisposableListener, append, EventType } from '../../../../base/browser/dom.js';
+import { Emitter, Event } from '../../../../base/common/event.js';
+import { ThemeIcon } from '../../../../base/common/themables.js';
+import { Codicon } from '../../../../base/common/codicons.js';
+import { localize } from '../../../../nls.js';
+import { autorun } from '../../../../base/common/observable.js';
+import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
+import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
+import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js';
+import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
+import { IsNewChatSessionContext } from '../../../common/contextkeys.js';
+import { Menus } from '../../menus.js';
+
+/**
+ * Mobile titlebar — prepended above the workbench grid on phone viewports
+ * in place of the desktop titlebar.
+ *
+ * Layout:
+ *
+ * `[menu] [session title | host widget] [+]`
+ *
+ * The center slot switches content based on whether the sessions welcome
+ * (home/empty) screen is visible:
+ *
+ * - **Welcome hidden** → shows the active session title (live, from
+ * {@link ISessionsManagementService.activeSession}).
+ * - **Welcome visible** → shows whatever is contributed to the
+ * {@link Menus.MobileTitleBarCenter} menu. On web, the host filter
+ * contribution appends its host dropdown + connection button there.
+ *
+ * The switch is driven entirely by the menu: when the toolbar has no
+ * items the title is shown; as soon as it has items the title is hidden
+ * and the toolbar fills the slot.
+ */
+export class MobileTitlebarPart extends Disposable {
+
+ readonly element: HTMLElement;
+
+ private readonly sessionTitleElement: HTMLElement;
+ private readonly actionsContainer: HTMLElement;
+
+ private readonly _onDidClickHamburger = this._register(new Emitter());
+ readonly onDidClickHamburger: Event = this._onDidClickHamburger.event;
+
+ private readonly _onDidClickNewSession = this._register(new Emitter());
+ readonly onDidClickNewSession: Event = this._onDidClickNewSession.event;
+
+ private readonly _onDidClickTitle = this._register(new Emitter());
+ readonly onDidClickTitle: Event = this._onDidClickTitle.event;
+
+ constructor(
+ parent: HTMLElement,
+ @IInstantiationService instantiationService: IInstantiationService,
+ @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService,
+ @IContextKeyService contextKeyService: IContextKeyService,
+ ) {
+ super();
+
+ this.element = document.createElement('div');
+ this.element.className = 'mobile-top-bar';
+
+ // Register DOM removal before appending so that any exception
+ // between this point and the end of the constructor still cleans
+ // up the element via disposal.
+ this._register(toDisposable(() => this.element.remove()));
+ parent.prepend(this.element);
+
+ // Hamburger button
+ const hamburger = append(this.element, $('button.mobile-top-bar-button'));
+ hamburger.setAttribute('aria-label', localize('mobileTopBar.openSessions', "Open sessions"));
+ const hamburgerIcon = append(hamburger, $('span'));
+ hamburgerIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.menu));
+ this._register(addDisposableListener(hamburger, EventType.CLICK, () => this._onDidClickHamburger.fire()));
+
+ // Center slot: title and/or actions container (mutually exclusive)
+ const center = append(this.element, $('div.mobile-top-bar-center'));
+
+ this.sessionTitleElement = append(center, $('button.mobile-session-title'));
+ this.sessionTitleElement.setAttribute('type', 'button');
+ this.sessionTitleElement.textContent = localize('mobileTopBar.newSession', "New Session");
+ this._register(addDisposableListener(this.sessionTitleElement, EventType.CLICK, () => this._onDidClickTitle.fire()));
+
+ this.actionsContainer = append(center, $('div.mobile-top-bar-actions'));
+
+ // New session button (+)
+ const newSession = append(this.element, $('button.mobile-top-bar-button'));
+ newSession.setAttribute('aria-label', localize('mobileTopBar.newSessionAria', "New session"));
+ const newSessionIcon = append(newSession, $('span'));
+ newSessionIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.plus));
+ this._register(addDisposableListener(newSession, EventType.CLICK, () => this._onDidClickNewSession.fire()));
+
+ // Keep the title in sync with the active session
+ this._register(autorun(reader => {
+ const session = this.sessionsManagementService.activeSession.read(reader);
+ const title = session?.title.read(reader);
+ this.sessionTitleElement.textContent = title || localize('mobileTopBar.newSession', "New Session");
+ }));
+
+ // Mount the center toolbar (host filter widget on web welcome, etc.)
+ const toolbar = this._register(instantiationService.createInstance(MenuWorkbenchToolBar, this.actionsContainer, Menus.MobileTitleBarCenter, {
+ hiddenItemStrategy: HiddenItemStrategy.NoHide,
+ telemetrySource: 'mobileTitlebar.center',
+ toolbarOptions: { primaryGroup: () => true },
+ }));
+
+ // Switch between title and toolbar based on whether a new (empty)
+ // chat session is active AND whether the toolbar has anything to
+ // show. The latter is important because on desktop/electron or
+ // when no agent hosts are configured the toolbar can be empty —
+ // in that case we keep the title visible.
+ const newChatKeySet = new Set([IsNewChatSessionContext.key]);
+ const updateCenterMode = () => {
+ const isNewChat = !!IsNewChatSessionContext.getValue(contextKeyService);
+ const hasActions = toolbar.getItemsLength() > 0;
+ this.element.classList.toggle('show-actions', isNewChat && hasActions);
+ };
+ updateCenterMode();
+ this._register(contextKeyService.onDidChangeContext(e => {
+ if (e.affectsSome(newChatKeySet)) {
+ updateCenterMode();
+ }
+ }));
+ this._register(toolbar.onDidChangeMenuItems(() => updateCenterMode()));
+ }
+
+ /**
+ * Explicitly set the title shown in the center slot. Called only when
+ * overriding the live session title (tests, placeholders). The live
+ * subscription will overwrite this on the next session change.
+ */
+ setTitle(title: string): void {
+ this.sessionTitleElement.textContent = title;
+ }
+}
diff --git a/src/vs/sessions/browser/parts/mobile/mobileTopBar.ts b/src/vs/sessions/browser/parts/mobile/mobileTopBar.ts
deleted file mode 100644
index 26a1aed9642d9..0000000000000
--- a/src/vs/sessions/browser/parts/mobile/mobileTopBar.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-/*---------------------------------------------------------------------------------------------
- * Copyright (c) Microsoft Corporation. All rights reserved.
- * Licensed under the MIT License. See License.txt in the project root for license information.
- *--------------------------------------------------------------------------------------------*/
-
-import './mobileChatShell.css';
-import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js';
-import { $, addDisposableListener, append, EventType } from '../../../../base/browser/dom.js';
-import { Emitter, Event } from '../../../../base/common/event.js';
-import { ThemeIcon } from '../../../../base/common/themables.js';
-import { Codicon } from '../../../../base/common/codicons.js';
-import { localize } from '../../../../nls.js';
-
-/**
- * Mobile top bar component — a simple DOM element prepended to the
- * workbench container on phone viewports. Replaces the desktop titlebar
- * with a native-feeling mobile app bar.
- *
- * Layout: [hamburger] [session title] [+ new]
- */
-export class MobileTopBar extends Disposable {
-
- readonly element: HTMLElement;
-
- private readonly sessionTitleElement: HTMLElement;
-
- private readonly _onDidClickHamburger = this._register(new Emitter());
- readonly onDidClickHamburger: Event = this._onDidClickHamburger.event;
-
- private readonly _onDidClickNewSession = this._register(new Emitter());
- readonly onDidClickNewSession: Event = this._onDidClickNewSession.event;
-
- private readonly _onDidClickTitle = this._register(new Emitter());
- readonly onDidClickTitle: Event = this._onDidClickTitle.event;
-
- constructor(parent: HTMLElement) {
- super();
-
- this.element = document.createElement('div');
- this.element.className = 'mobile-top-bar';
-
- // Register DOM removal before appending so that any exception
- // between this point and the end of the constructor still cleans
- // up the element via disposal.
- this._register(toDisposable(() => this.element.remove()));
- parent.prepend(this.element);
-
- // Hamburger button
- const hamburger = append(this.element, $('button.mobile-top-bar-button'));
- hamburger.setAttribute('aria-label', 'Open sessions');
- const hamburgerIcon = append(hamburger, $('span'));
- hamburgerIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.menu));
- this._register(addDisposableListener(hamburger, EventType.CLICK, () => this._onDidClickHamburger.fire()));
-
- // Session title
- this.sessionTitleElement = append(this.element, $('div.mobile-session-title'));
- this.sessionTitleElement.textContent = localize('mobileTopBar.newSession', "New Session");
- this._register(addDisposableListener(this.sessionTitleElement, EventType.CLICK, () => this._onDidClickTitle.fire()));
-
- // New session button (+)
- const newSession = append(this.element, $('button.mobile-top-bar-button'));
- newSession.setAttribute('aria-label', 'New session');
- const newSessionIcon = append(newSession, $('span'));
- newSessionIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.plus));
- this._register(addDisposableListener(newSession, EventType.CLICK, () => this._onDidClickNewSession.fire()));
- }
-
- setTitle(title: string): void {
- this.sessionTitleElement.textContent = title;
- }
-}
diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts
index 3789111d77c67..de3c9c25d0f43 100644
--- a/src/vs/sessions/browser/workbench.ts
+++ b/src/vs/sessions/browser/workbench.ts
@@ -71,7 +71,7 @@ import {
} from '../../workbench/common/notifications.js';
import { SessionsLayoutPolicy } from './layoutPolicy.js';
import { MobileNavigationStack } from './mobileNavigationStack.js';
-import { MobileTopBar } from './parts/mobile/mobileTopBar.js';
+import { MobileTitlebarPart } from './parts/mobile/mobileTitlebarPart.js';
import { autorun } from '../../base/common/observable.js';
import { ISessionsManagementService } from '../services/sessions/common/sessionsManagement.js';
@@ -240,7 +240,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic
top = this.getPart(Parts.TITLEBAR_PART).maximumHeight;
quickPickTop = top;
} else if (this.mobileTopBarElement) {
- // On phone layout the MobileTopBar replaces the titlebar
+ // On phone layout the MobileTitlebarPart replaces the titlebar
top = this.mobileTopBarElement.offsetHeight;
quickPickTop = top;
}
@@ -296,6 +296,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic
private paneCompositeService!: IPaneCompositePartService;
private viewDescriptorService!: IViewDescriptorService;
private sessionsManagementService!: ISessionsManagementService;
+ private instantiationService!: IInstantiationService;
//#endregion
@@ -472,7 +473,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic
// Create mobile navigation after grid exists (so DOM order is correct)
if (this.layoutPolicy.viewportClass.get() === 'phone') {
- this.createMobileTopBar();
+ this.createMobileTitlebar();
}
// Workbench Management
@@ -680,18 +681,18 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic
this.parent.appendChild(this.mainContainer);
}
- private createMobileTopBar(): void {
+ private createMobileTitlebar(): void {
this.mobileTopBarDisposables.clear();
- const mobileTopBar = this.mobileTopBarDisposables.add(new MobileTopBar(this.mainContainer));
- this.mobileTopBarElement = mobileTopBar.element;
+ const mobileTitlebar = this.mobileTopBarDisposables.add(this.instantiationService.createInstance(MobileTitlebarPart, this.mainContainer));
+ this.mobileTopBarElement = mobileTitlebar.element;
// Hamburger: toggle sidebar drawer overlay
- this.mobileTopBarDisposables.add(mobileTopBar.onDidClickHamburger(() => {
+ this.mobileTopBarDisposables.add(mobileTitlebar.onDidClickHamburger(() => {
this.toggleMobileSidebarDrawer();
}));
// New session: open new chat view
- this.mobileTopBarDisposables.add(mobileTopBar.onDidClickNewSession(() => {
+ this.mobileTopBarDisposables.add(mobileTitlebar.onDidClickNewSession(() => {
this.sessionsManagementService.openNewSessionView();
}));
}
@@ -900,6 +901,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic
this.paneCompositeService = accessor.get(IPaneCompositePartService);
this.viewDescriptorService = accessor.get(IViewDescriptorService);
this.sessionsManagementService = accessor.get(ISessionsManagementService);
+ this.instantiationService = accessor.get(IInstantiationService);
accessor.get(ITitleService);
// Register layout listeners
@@ -1074,7 +1076,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic
/**
* Standard multi-part layout for all viewport classes.
- * On phone, the titlebar is hidden via CSS and a MobileTopBar
+ * On phone, the titlebar is hidden via CSS and a MobileTitlebarPart
* is prepended before the grid. Sidebar/panel/auxbar are hidden
* in the grid via partVisibility defaults.
*/
@@ -1202,8 +1204,8 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic
// update part visibility and create/destroy mobile components
if (previousClass !== undefined && previousClass !== currentClass) {
if (currentClass === 'phone' && !this.mobileTopBarElement) {
- this.createMobileTopBar();
- // Hide titlebar in grid on phone (replaced by MobileTopBar)
+ this.createMobileTitlebar();
+ // Hide titlebar in grid on phone (replaced by MobileTitlebarPart)
this.workbenchGrid.setViewVisible(this.titleBarPartView, false);
// On phone, only chat is visible — hide everything else first
const defaults = this.layoutPolicy.getPartVisibilityDefaults();
@@ -1428,7 +1430,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic
isVisible(part: Parts, targetWindow?: Window): boolean {
switch (part) {
case Parts.TITLEBAR_PART:
- // On phone layout the grid titlebar is hidden (replaced by MobileTopBar)
+ // On phone layout the grid titlebar is hidden (replaced by MobileTitlebarPart)
return this.layoutPolicy.viewportClass.get() !== 'phone';
case Parts.SIDEBAR_PART:
return this.partVisibility.sidebar;
diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts
index b1bdfe7fef46c..c1382b3b248f1 100644
--- a/src/vs/sessions/common/agentHostSessionsProvider.ts
+++ b/src/vs/sessions/common/agentHostSessionsProvider.ts
@@ -8,6 +8,7 @@ import { IObservable } from '../../base/common/observable.js';
import { equals } from '../../base/common/objects.js';
import { RemoteAgentHostConnectionStatus } from '../../platform/agentHost/common/remoteAgentHostService.js';
import { ResolveSessionConfigResult, SessionConfigValueItem } from '../../platform/agentHost/common/state/protocol/commands.js';
+import { RootConfigState } from '../../platform/agentHost/common/state/protocol/state.js';
import { ISessionsProvider } from '../services/sessions/common/sessionsProvider.js';
/**
@@ -66,6 +67,27 @@ export interface IAgentHostSessionsProvider extends ISessionsProvider {
getCreateSessionConfig(sessionId: string): Record | undefined;
/** Clears dynamic configuration state for an abandoned new session. */
clearSessionConfig(sessionId: string): void;
+
+ // -- Root (agent host) Config --
+
+ /** Fires when the root (agent host) configuration schema or values change. */
+ readonly onDidChangeRootConfig: Event;
+ /** Returns the last-known root (agent host) configuration, or `undefined` if the host has not published any. */
+ getRootConfig(): RootConfigState | undefined;
+ /**
+ * Sets one root configuration property.
+ *
+ * Optimistically updates local state and dispatches a
+ * `root/configChanged` action (non-replace) to the agent host.
+ */
+ setRootConfigValue(property: string, value: unknown): Promise;
+ /**
+ * Replaces the full set of root configuration values atomically.
+ *
+ * Dispatches a single `root/configChanged` action with replace semantics.
+ * Unknown keys (no schema entry) are ignored.
+ */
+ replaceRootConfig(values: Record): Promise;
}
export const LOCAL_AGENT_HOST_PROVIDER_ID = 'local-agent-host';
diff --git a/src/vs/sessions/contrib/agentHost/browser/agentHostSettings.contribution.ts b/src/vs/sessions/contrib/agentHost/browser/agentHostSettings.contribution.ts
new file mode 100644
index 0000000000000..6c863a4281ba0
--- /dev/null
+++ b/src/vs/sessions/contrib/agentHost/browser/agentHostSettings.contribution.ts
@@ -0,0 +1,74 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { Disposable } from '../../../../base/common/lifecycle.js';
+import { localize, localize2 } from '../../../../nls.js';
+import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js';
+import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
+import { IFileService } from '../../../../platform/files/common/files.js';
+import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
+import { ILabelService } from '../../../../platform/label/common/label.js';
+import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js';
+import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js';
+import { ChatSessionProviderIdContext } from '../../../common/contextkeys.js';
+import { ISession } from '../../../services/sessions/common/session.js';
+import { SessionItemContextMenuId } from '../../sessions/browser/views/sessionsList.js';
+import { agentHostSettingsUri, AGENT_HOST_SETTINGS_SCHEME, AgentHostSettingsFileSystemProvider, AgentHostSettingsSchemaRegistrar } from './agentHostSettingsFileSystemProvider.js';
+import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../common/agentHostSessionsProvider.js';
+
+/**
+ * Registers the {@link AgentHostSettingsFileSystemProvider} with the
+ * {@link IFileService} and contributes the "Open Host Settings" action.
+ */
+class AgentHostSettingsContribution extends Disposable implements IWorkbenchContribution {
+
+ static readonly ID = 'sessions.contrib.agentHostSettingsContribution';
+
+ constructor(
+ @IFileService fileService: IFileService,
+ @IInstantiationService instantiationService: IInstantiationService,
+ @ILabelService labelService: ILabelService,
+ ) {
+ super();
+
+ const schemaRegistrar = this._register(instantiationService.createInstance(AgentHostSettingsSchemaRegistrar));
+ const provider = this._register(instantiationService.createInstance(AgentHostSettingsFileSystemProvider, schemaRegistrar));
+ this._register(fileService.registerProvider(AGENT_HOST_SETTINGS_SCHEME, provider));
+
+ this._register(labelService.registerFormatter({
+ scheme: AGENT_HOST_SETTINGS_SCHEME,
+ formatting: {
+ label: localize('agentHostSettings.label', "Host Settings"),
+ separator: '/',
+ },
+ }));
+ }
+}
+
+registerWorkbenchContribution2(AgentHostSettingsContribution.ID, AgentHostSettingsContribution, WorkbenchPhase.AfterRestored);
+
+registerAction2(class OpenHostSettingsAction extends Action2 {
+ constructor() {
+ super({
+ id: 'sessionsViewPane.openHostSettings',
+ title: localize2('openHostSettings', "Open Host Settings"),
+ menu: [{
+ id: SessionItemContextMenuId,
+ group: '2_settings',
+ order: 2,
+ when: ContextKeyExpr.regex(ChatSessionProviderIdContext.key, ANY_AGENT_HOST_PROVIDER_RE),
+ }]
+ });
+ }
+ async run(accessor: ServicesAccessor, context?: ISession | ISession[]): Promise {
+ const session = Array.isArray(context) ? context[0] : context;
+ if (!session) {
+ return;
+ }
+ const editorService = accessor.get(IEditorService);
+ const resource = agentHostSettingsUri(session.providerId);
+ await editorService.openEditor({ resource, options: { pinned: true } });
+ }
+});
diff --git a/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsFileSystemProvider.ts b/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsFileSystemProvider.ts
new file mode 100644
index 0000000000000..75127ddc4622d
--- /dev/null
+++ b/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsFileSystemProvider.ts
@@ -0,0 +1,157 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { IJSONSchema } from '../../../../base/common/jsonSchema.js';
+import { IDisposable } from '../../../../base/common/lifecycle.js';
+import { URI } from '../../../../base/common/uri.js';
+import { localize } from '../../../../nls.js';
+import { ILogService } from '../../../../platform/log/common/log.js';
+import { RootConfigState } from '../../../../platform/agentHost/common/state/protocol/state.js';
+import { IAgentHostSessionsProvider } from '../../../common/agentHostSessionsProvider.js';
+import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';
+import {
+ AbstractAgentHostConfigFileSystemProvider,
+ AbstractAgentHostConfigSchemaRegistrar,
+ AgentHostConfigPropertyFilter,
+ buildAgentHostConfigJsonSchema,
+ IAgentHostConfigLike,
+ IAgentHostSettingsContext,
+ IAgentHostSettingsLocale,
+ serializeAgentHostConfigDocument,
+} from './agentHostSettingsShared.js';
+
+/** Scheme for the synthetic agent-host settings files. */
+export const AGENT_HOST_SETTINGS_SCHEME = 'agent-host-settings';
+
+/**
+ * Build the URI used to open the settings file for an agent host provider.
+ *
+ * URI shape: `agent-host-settings://{providerId}/settings.jsonc`
+ */
+export function agentHostSettingsUri(providerId: string): URI {
+ return URI.from({
+ scheme: AGENT_HOST_SETTINGS_SCHEME,
+ authority: providerId,
+ path: `/settings.jsonc`,
+ });
+}
+
+function parseHostSettingsUri(uri: URI): IAgentHostSettingsContext | undefined {
+ if (uri.scheme !== AGENT_HOST_SETTINGS_SCHEME) {
+ return undefined;
+ }
+ const providerId = uri.authority;
+ if (!providerId) {
+ return undefined;
+ }
+ return { providerId };
+}
+
+/** Root (agent host) config exposes no per-property mutability flags — all props are editable. */
+const hostSettingsPropertyFilter: AgentHostConfigPropertyFilter = () => true;
+
+const hostSettingsLocale: IAgentHostSettingsLocale = {
+ get header() { return localize('agentHostSettings.header', "Agent host settings."); },
+ get saveHint() { return localize('agentHostSettings.saveHint', "Edit values below and save to apply. Unknown properties are ignored."); },
+ get parseError() { return localize('agentHostSettings.parseError', "Failed to parse agent host settings as JSON."); },
+ get notObject() { return localize('agentHostSettings.notObject', "Agent host settings must be a JSON object."); },
+};
+
+/**
+ * Serialize the root config values for an agent host provider into a
+ * commented, pretty-printed JSON document.
+ */
+export function serializeHostSettings(provider: IAgentHostSessionsProvider): string {
+ return serializeAgentHostConfigDocument(provider.getRootConfig(), hostSettingsPropertyFilter, hostSettingsLocale);
+}
+
+/**
+ * Build a JSON schema describing the root config of an agent host provider.
+ */
+export function buildHostSettingsJsonSchema(config: RootConfigState): IJSONSchema {
+ return buildAgentHostConfigJsonSchema(config, hostSettingsPropertyFilter);
+}
+
+/**
+ * Filesystem provider serving synthetic JSONC documents representing the
+ * root (agent host) configuration values of agent-host providers.
+ */
+export class AgentHostSettingsFileSystemProvider extends AbstractAgentHostConfigFileSystemProvider {
+
+ protected readonly _schemeLabel = AGENT_HOST_SETTINGS_SCHEME;
+ protected readonly _traceTag = 'AgentHostSettings';
+ protected readonly _locale = hostSettingsLocale;
+
+ constructor(
+ private readonly _schemaRegistrar: AgentHostSettingsSchemaRegistrar,
+ @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService,
+ @ILogService logService: ILogService,
+ ) {
+ super(sessionsProvidersService, logService);
+ }
+
+ protected _parseUri(resource: URI): IAgentHostSettingsContext | undefined {
+ return parseHostSettingsUri(resource);
+ }
+
+ protected _serialize(provider: IAgentHostSessionsProvider): string {
+ return serializeHostSettings(provider);
+ }
+
+ protected _watchChanges(provider: IAgentHostSessionsProvider, _ctx: IAgentHostSettingsContext, fire: () => void): IDisposable {
+ return provider.onDidChangeRootConfig(() => fire());
+ }
+
+ protected _ensureSchemaRegistered(provider: IAgentHostSessionsProvider): void {
+ this._schemaRegistrar.ensureRegistered(provider, provider);
+ }
+
+ protected _hasConfig(provider: IAgentHostSessionsProvider): boolean {
+ return provider.getRootConfig() !== undefined;
+ }
+
+ protected _replaceConfig(provider: IAgentHostSessionsProvider, _ctx: IAgentHostSettingsContext, values: Record): Promise {
+ return provider.replaceRootConfig(values);
+ }
+
+ protected _describeForTrace(ctx: IAgentHostSettingsContext): string {
+ return `provider ${ctx.providerId}`;
+ }
+}
+
+/**
+ * Keeps per-provider JSON schemas registered so editors of the synthetic
+ * `agent-host-settings://…` files get completions, hover, and validation.
+ */
+export class AgentHostSettingsSchemaRegistrar extends AbstractAgentHostConfigSchemaRegistrar {
+
+ protected _propertyFilter(): AgentHostConfigPropertyFilter {
+ return hostSettingsPropertyFilter;
+ }
+
+ protected _settingsUri(provider: IAgentHostSessionsProvider): string {
+ return agentHostSettingsUri(provider.id).toString();
+ }
+
+ protected _schemaId(provider: IAgentHostSessionsProvider): string {
+ return `vscode://schemas/agent-host-settings/${provider.id}.jsonc`;
+ }
+
+ protected _getConfig(_provider: IAgentHostSessionsProvider, target: IAgentHostSessionsProvider): IAgentHostConfigLike | undefined {
+ return target.getRootConfig();
+ }
+
+ protected _targetsForProvider(provider: IAgentHostSessionsProvider): readonly IAgentHostSessionsProvider[] {
+ return [provider];
+ }
+
+ protected _observeProvider(
+ provider: IAgentHostSessionsProvider,
+ onChanged: (target: IAgentHostSessionsProvider) => void,
+ _onRemoved: (target: IAgentHostSessionsProvider) => void,
+ ): IDisposable {
+ return provider.onDidChangeRootConfig(() => onChanged(provider));
+ }
+}
diff --git a/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsShared.ts b/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsShared.ts
new file mode 100644
index 0000000000000..e29f5b904d136
--- /dev/null
+++ b/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsShared.ts
@@ -0,0 +1,512 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { VSBuffer } from '../../../../base/common/buffer.js';
+import { Emitter } from '../../../../base/common/event.js';
+import { parse, ParseError } from '../../../../base/common/json.js';
+import { IJSONSchema } from '../../../../base/common/jsonSchema.js';
+import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
+import { URI } from '../../../../base/common/uri.js';
+import {
+ createFileSystemProviderError,
+ FileChangeType,
+ FilePermission,
+ FileSystemProviderCapabilities,
+ FileSystemProviderErrorCode,
+ FileType,
+ IFileChange,
+ IFileDeleteOptions,
+ IFileOverwriteOptions,
+ IFileSystemProviderWithFileReadWriteCapability,
+ IFileWriteOptions,
+ IStat,
+ IWatchOptions,
+} from '../../../../platform/files/common/files.js';
+import { Extensions as JSONExtensions, IJSONContributionRegistry } from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js';
+import { ILogService } from '../../../../platform/log/common/log.js';
+import { Registry } from '../../../../platform/registry/common/platform.js';
+import { ConfigPropertySchema, ConfigSchema } from '../../../../platform/agentHost/common/state/protocol/state.js';
+import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';
+import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js';
+import { ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js';
+
+// ============================================================================
+// Shared helpers for agent-host config settings filesystem providers.
+//
+// Both the per-session (`agent-session-settings://...`) and the per-host
+// (`agent-host-settings://...`) synthetic settings editors follow the same
+// shape: they render a provider's config schema as a JSONC document, watch
+// for config changes, and round-trip user edits through a
+// `replace*Config` API. This module factors out that shared plumbing.
+// ============================================================================
+
+/**
+ * Minimal config shape shared by session ({@link ResolveSessionConfigResult})
+ * and root ({@link RootConfigState}) configuration.
+ */
+export interface IAgentHostConfigLike {
+ readonly schema: ConfigSchema;
+ readonly values: Record;
+}
+
+/**
+ * Filter applied to schema properties to decide which ones surface in the
+ * editable document (and in the derived JSON schema).
+ *
+ * For session settings this filters to `sessionMutable && !readOnly`. For
+ * host settings all properties are editable, so the filter is a constant
+ * `true`.
+ */
+export type AgentHostConfigPropertyFilter = (key: string, schema: ConfigPropertySchema) => boolean;
+
+/**
+ * Localized strings used to decorate the serialized JSONC document.
+ */
+export interface IAgentHostSettingsLocale {
+ /** Header comment line describing the document. */
+ readonly header: string;
+ /** Secondary hint comment describing save semantics. */
+ readonly saveHint: string;
+ /** Error message thrown when the document fails to parse as JSONC. */
+ readonly parseError: string;
+ /** Error message thrown when the parsed document is not a JSON object. */
+ readonly notObject: string;
+}
+
+/**
+ * Convert a config property schema (protocol shape) into an
+ * {@link IJSONSchema} suitable for registration with the JSON language
+ * service.
+ */
+export function convertPropertySchema(schema: ConfigPropertySchema): IJSONSchema {
+ const out: IJSONSchema = {
+ type: schema.type,
+ title: schema.title,
+ description: schema.description,
+ default: schema.default,
+ };
+ if (schema.enum && schema.enum.length > 0) {
+ out.enum = [...schema.enum];
+ if (schema.enumDescriptions && schema.enumDescriptions.length > 0) {
+ out.enumDescriptions = [...schema.enumDescriptions];
+ }
+ }
+ if (schema.type === 'array' && schema.items) {
+ out.items = convertPropertySchema(schema.items);
+ }
+ if (schema.type === 'object' && schema.properties) {
+ const properties: Record = {};
+ for (const [key, value] of Object.entries(schema.properties)) {
+ properties[key] = convertPropertySchema(value);
+ }
+ out.properties = properties;
+ if (schema.required && schema.required.length > 0) {
+ out.required = [...schema.required];
+ }
+ }
+ return out;
+}
+
+/**
+ * Build a JSON schema describing the filtered properties of an agent-host
+ * config. Properties that pass {@link filter} are included; others are
+ * dropped. `required` entries are carried through when the referenced
+ * property survives the filter.
+ */
+export function buildAgentHostConfigJsonSchema(config: IAgentHostConfigLike, filter: AgentHostConfigPropertyFilter): IJSONSchema {
+ const properties: Record = {};
+ const required: string[] = [];
+ for (const [key, schema] of Object.entries(config.schema.properties)) {
+ if (!filter(key, schema)) {
+ continue;
+ }
+ properties[key] = convertPropertySchema(schema);
+ if (config.schema.required?.includes(key)) {
+ required.push(key);
+ }
+ }
+ const result: IJSONSchema = {
+ type: 'object',
+ properties,
+ additionalProperties: true,
+ };
+ if (required.length > 0) {
+ result.required = required;
+ }
+ return result;
+}
+
+function buildHeaderComment(
+ locale: IAgentHostSettingsLocale,
+ props: readonly (readonly [string, ConfigPropertySchema])[] | undefined,
+): string {
+ const lines: string[] = [];
+ lines.push(`// ${locale.header}`);
+ lines.push(`// ${locale.saveHint}`);
+ if (props && props.length > 0) {
+ lines.push('//');
+ for (const [key, schema] of props) {
+ const suffix = schema.enum && schema.enum.length > 0 ? ` (${schema.enum.join(' | ')})` : '';
+ const title = schema.title || key;
+ lines.push(`// ${key}: ${title}${suffix}`);
+ if (schema.description) {
+ lines.push(`// ${schema.description}`);
+ }
+ }
+ }
+ lines.push('');
+ return lines.join('\n');
+}
+
+/**
+ * Serialize the filtered config values into a commented, pretty-printed
+ * JSONC document.
+ */
+export function serializeAgentHostConfigDocument(
+ config: IAgentHostConfigLike | undefined,
+ filter: AgentHostConfigPropertyFilter,
+ locale: IAgentHostSettingsLocale,
+): string {
+ if (!config) {
+ return `${buildHeaderComment(locale, undefined)}{}\n`;
+ }
+
+ const editableProps = Object.entries(config.schema.properties).filter(([key, schema]) => filter(key, schema));
+ const values: Record = {};
+ for (const [key] of editableProps) {
+ if (config.values[key] !== undefined) {
+ values[key] = config.values[key];
+ }
+ }
+
+ return `${buildHeaderComment(locale, editableProps)}${JSON.stringify(values, null, 2)}\n`;
+}
+
+// ============================================================================
+// AbstractAgentHostConfigFileSystemProvider
+// ============================================================================
+
+/**
+ * Base context shared by all settings filesystem providers. Subclasses
+ * extend with any additional state they need (e.g. a sessionId).
+ */
+export interface IAgentHostSettingsContext {
+ readonly providerId: string;
+}
+
+/**
+ * Abstract filesystem provider backing the synthetic agent-host settings
+ * JSONC editors. Subclasses supply scheme-specific URI parsing,
+ * config-fetching, change-watching, and replace-dispatch hooks; the base
+ * handles the boilerplate (`stat`/`readFile`/`writeFile`/error shapes).
+ */
+export abstract class AbstractAgentHostConfigFileSystemProvider extends Disposable implements IFileSystemProviderWithFileReadWriteCapability {
+
+ readonly capabilities = FileSystemProviderCapabilities.FileReadWrite | FileSystemProviderCapabilities.PathCaseSensitive;
+
+ private readonly _onDidChangeCapabilities = this._register(new Emitter());
+ readonly onDidChangeCapabilities = this._onDidChangeCapabilities.event;
+
+ protected readonly _onDidChangeFile = this._register(new Emitter());
+ readonly onDidChangeFile = this._onDidChangeFile.event;
+
+ constructor(
+ @ISessionsProvidersService protected readonly _sessionsProvidersService: ISessionsProvidersService,
+ @ILogService protected readonly _logService: ILogService,
+ ) {
+ super();
+ }
+
+ // ---- Subclass hooks -----------------------------------------------------
+
+ /** URI scheme label used in error messages (e.g. `'agent-session-settings'`). */
+ protected abstract readonly _schemeLabel: string;
+
+ /** Log trace-tag (e.g. `'AgentSessionSettings'`). */
+ protected abstract readonly _traceTag: string;
+
+ /** Localized strings for the JSONC document and write-path errors. */
+ protected abstract readonly _locale: IAgentHostSettingsLocale;
+
+ /** Parse a URI of the subclass's scheme into a typed context. */
+ protected abstract _parseUri(resource: URI): TContext | undefined;
+
+ /** Render the current config for a context as a JSONC document. */
+ protected abstract _serialize(provider: IAgentHostSessionsProvider, ctx: TContext): string;
+
+ /**
+ * Subscribe for changes relevant to the given context. When a change is
+ * detected the subclass should invoke {@link fire}.
+ */
+ protected abstract _watchChanges(provider: IAgentHostSessionsProvider, ctx: TContext, fire: () => void): IDisposable;
+
+ /** Register / refresh the JSON schema for the given context. */
+ protected abstract _ensureSchemaRegistered(provider: IAgentHostSessionsProvider, ctx: TContext): void;
+
+ /** Whether the backing config is currently available. */
+ protected abstract _hasConfig(provider: IAgentHostSessionsProvider, ctx: TContext): boolean;
+
+ /** Dispatch a replace write of the parsed JSONC document. */
+ protected abstract _replaceConfig(provider: IAgentHostSessionsProvider, ctx: TContext, values: Record): Promise;
+
+ /**
+ * Build a short human-readable description of `ctx` for log messages
+ * when a write is ignored due to missing config (e.g. a session id).
+ */
+ protected abstract _describeForTrace(ctx: TContext): string;
+
+ // ---- IFileSystemProvider ------------------------------------------------
+
+ watch(resource: URI, _opts: IWatchOptions): IDisposable {
+ const parsed = this._parseUri(resource);
+ if (!parsed) {
+ return Disposable.None;
+ }
+ const provider = this._lookupProvider(parsed.providerId);
+ if (!provider) {
+ return Disposable.None;
+ }
+ return this._watchChanges(provider, parsed, () => {
+ this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]);
+ });
+ }
+
+ async stat(resource: URI): Promise {
+ const { provider, ctx } = this._resolveOrThrow(resource);
+ const content = this._serialize(provider, ctx);
+ return {
+ type: FileType.File,
+ ctime: 0,
+ mtime: 0,
+ size: VSBuffer.fromString(content).byteLength,
+ permissions: 0 as FilePermission,
+ };
+ }
+
+ async readdir(): Promise<[string, FileType][]> {
+ throw createFileSystemProviderError('readdir not supported', FileSystemProviderErrorCode.NoPermissions);
+ }
+
+ async readFile(resource: URI): Promise {
+ const { provider, ctx } = this._resolveOrThrow(resource);
+ const content = this._serialize(provider, ctx);
+
+ // Register the JSON schema on demand the first time a settings file
+ // is read. The subclass keeps it in sync from then on.
+ this._ensureSchemaRegistered(provider, ctx);
+
+ return VSBuffer.fromString(content).buffer;
+ }
+
+ async writeFile(resource: URI, content: Uint8Array, _opts: IFileWriteOptions): Promise {
+ const { provider, ctx } = this._resolveOrThrow(resource);
+
+ const text = VSBuffer.wrap(content).toString();
+ const errors: ParseError[] = [];
+ const parsed_json = parse(text, errors);
+ if (errors.length > 0) {
+ throw createFileSystemProviderError(this._locale.parseError, FileSystemProviderErrorCode.Unavailable);
+ }
+ if (parsed_json === null || typeof parsed_json !== 'object' || Array.isArray(parsed_json)) {
+ throw createFileSystemProviderError(this._locale.notObject, FileSystemProviderErrorCode.Unavailable);
+ }
+
+ if (!this._hasConfig(provider, ctx)) {
+ this._logService.trace(`[${this._traceTag}] No config state for ${this._describeForTrace(ctx)}; ignoring write.`);
+ this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]);
+ return;
+ }
+
+ await this._replaceConfig(provider, ctx, parsed_json as Record);
+
+ this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]);
+ }
+
+ async mkdir(): Promise {
+ throw createFileSystemProviderError('mkdir not supported', FileSystemProviderErrorCode.NoPermissions);
+ }
+
+ async delete(_resource: URI, _opts: IFileDeleteOptions): Promise {
+ throw createFileSystemProviderError('delete not supported', FileSystemProviderErrorCode.NoPermissions);
+ }
+
+ async rename(_from: URI, _to: URI, _opts: IFileOverwriteOptions): Promise {
+ throw createFileSystemProviderError('rename not supported', FileSystemProviderErrorCode.NoPermissions);
+ }
+
+ // ---- Helpers ------------------------------------------------------------
+
+ protected _lookupProvider(providerId: string): IAgentHostSessionsProvider | undefined {
+ const provider = this._sessionsProvidersService.getProvider(providerId);
+ if (!provider || !isAgentHostProvider(provider)) {
+ return undefined;
+ }
+ return provider;
+ }
+
+ private _resolveOrThrow(resource: URI): { provider: IAgentHostSessionsProvider; ctx: TContext } {
+ const ctx = this._parseUri(resource);
+ if (!ctx) {
+ throw createFileSystemProviderError(`Invalid ${this._schemeLabel} URI: ${resource.toString()}`, FileSystemProviderErrorCode.FileNotFound);
+ }
+ const provider = this._lookupProvider(ctx.providerId);
+ if (!provider) {
+ throw createFileSystemProviderError(`Unknown agent host provider: ${ctx.providerId}`, FileSystemProviderErrorCode.FileNotFound);
+ }
+ return { provider, ctx };
+ }
+}
+
+// ============================================================================
+// AbstractAgentHostConfigSchemaRegistrar
+// ============================================================================
+
+/**
+ * Abstract base for the schema registrars that keep JSON schemas registered
+ * on the {@link IJSONContributionRegistry} for the synthetic settings
+ * editors. Subclasses plumb per-provider subscriptions and the target-type
+ * that identifies what a schema belongs to (an `ISession` for the session
+ * editor, an `IAgentHostSessionsProvider` for the host editor).
+ *
+ * Registration is lazy — {@link ensureRegistered} is called by the
+ * filesystem provider when a settings file is first read. Once registered,
+ * the schema is kept in sync via the subclass's change subscription until
+ * the provider is removed.
+ */
+export abstract class AbstractAgentHostConfigSchemaRegistrar extends Disposable {
+
+ private readonly _schemaRegistry = Registry.as(JSONExtensions.JSONContribution);
+
+ /** Per-provider subscriptions. */
+ private readonly _providerSubscriptions = this._register(new DisposableMap());
+
+ /** Per-target registered-schema disposables, keyed by the settings URI string. */
+ private readonly _targetSchemas = this._register(new DisposableMap());
+
+ /**
+ * Tracks the {@link ConfigSchema} identity last used to register a schema
+ * for a given settings URI so we can skip re-registration when only
+ * values have changed.
+ */
+ private readonly _lastSchemaIdentity = new Map();
+
+ constructor(
+ @ISessionsProvidersService protected readonly _sessionsProvidersService: ISessionsProvidersService,
+ ) {
+ super();
+
+ for (const provider of this._sessionsProvidersService.getProviders()) {
+ this._onProviderAdded(provider);
+ }
+ this._register(this._sessionsProvidersService.onDidChangeProviders(e => {
+ for (const provider of e.added) {
+ this._onProviderAdded(provider);
+ }
+ for (const provider of e.removed) {
+ this._providerSubscriptions.deleteAndDispose(provider.id);
+ }
+ }));
+ }
+
+ // ---- Subclass hooks -----------------------------------------------------
+
+ /** Stringified URI identifying the settings document for a target. */
+ protected abstract _settingsUri(target: TTarget): string;
+
+ /** `vscode://schemas/...` schema id used for JSON language service registration. */
+ protected abstract _schemaId(target: TTarget): string;
+
+ /** Fetch the backing config for a target. Returns `undefined` when none yet. */
+ protected abstract _getConfig(provider: IAgentHostSessionsProvider, target: TTarget): IAgentHostConfigLike | undefined;
+
+ /** Filter applied to schema properties when building the JSON schema. */
+ protected abstract _propertyFilter(): AgentHostConfigPropertyFilter;
+
+ /** Enumerate the targets currently tracked on a provider (used for cleanup). */
+ protected abstract _targetsForProvider(provider: IAgentHostSessionsProvider): readonly TTarget[];
+
+ /**
+ * Subscribe to change signals from {@link provider}. The subclass should
+ * invoke {@link onChanged} when a tracked target's config changes and
+ * {@link onRemoved} when a tracked target disappears.
+ */
+ protected abstract _observeProvider(
+ provider: IAgentHostSessionsProvider,
+ onChanged: (target: TTarget) => void,
+ onRemoved: (target: TTarget) => void,
+ ): IDisposable;
+
+ // ---- Public API ---------------------------------------------------------
+
+ /**
+ * Ensures a JSON schema is registered for the given target. Safe to
+ * call repeatedly; a no-op when the cached schema identity matches.
+ */
+ ensureRegistered(provider: IAgentHostSessionsProvider, target: TTarget): void {
+ this._refreshSchema(provider, target);
+ }
+
+ // ---- Internal -----------------------------------------------------------
+
+ private _onProviderAdded(provider: ISessionsProvider): void {
+ if (!isAgentHostProvider(provider)) {
+ return;
+ }
+ const store = new DisposableStore();
+
+ store.add(this._observeProvider(
+ provider,
+ target => {
+ // Only refresh if we already have a registration; otherwise the
+ // next `readFile` will pick up the latest schema on demand.
+ if (!this._lastSchemaIdentity.has(this._settingsUri(target))) {
+ return;
+ }
+ this._refreshSchema(provider, target);
+ },
+ target => this._disposeSchemaForTarget(target),
+ ));
+
+ // On provider disposal, drop all schemas registered for this provider.
+ store.add(toDisposable(() => {
+ for (const target of this._targetsForProvider(provider)) {
+ this._disposeSchemaForTarget(target);
+ }
+ }));
+
+ this._providerSubscriptions.set(provider.id, store);
+ }
+
+ private _refreshSchema(provider: IAgentHostSessionsProvider, target: TTarget): void {
+ const config = this._getConfig(provider, target);
+ if (!config) {
+ return;
+ }
+ const settingsUri = this._settingsUri(target);
+ const identity = config.schema;
+ if (this._lastSchemaIdentity.get(settingsUri) === identity) {
+ return;
+ }
+
+ const schema = buildAgentHostConfigJsonSchema(config, this._propertyFilter());
+ const schemaId = this._schemaId(target);
+
+ // Dispose any prior registration first, otherwise the old cleanup
+ // disposable would delete the freshly registered schema.
+ this._targetSchemas.deleteAndDispose(settingsUri);
+
+ const store = new DisposableStore();
+ this._schemaRegistry.registerSchema(schemaId, schema, store);
+ store.add(this._schemaRegistry.registerSchemaAssociation(schemaId, settingsUri));
+ store.add(toDisposable(() => this._lastSchemaIdentity.delete(settingsUri)));
+
+ this._targetSchemas.set(settingsUri, store);
+ this._lastSchemaIdentity.set(settingsUri, identity);
+ }
+
+ private _disposeSchemaForTarget(target: TTarget): void {
+ this._targetSchemas.deleteAndDispose(this._settingsUri(target));
+ }
+}
diff --git a/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts b/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts
index cd0caf3c3ace8..19c56865fbb4f 100644
--- a/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts
+++ b/src/vs/sessions/contrib/agentHost/browser/agentSessionSettingsFileSystemProvider.ts
@@ -3,37 +3,26 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
-import { VSBuffer } from '../../../../base/common/buffer.js';
-import { Emitter } from '../../../../base/common/event.js';
-import { parse, ParseError } from '../../../../base/common/json.js';
import { IJSONSchema } from '../../../../base/common/jsonSchema.js';
-import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
+import { DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js';
import { URI } from '../../../../base/common/uri.js';
import { localize } from '../../../../nls.js';
-import {
- createFileSystemProviderError,
- FileChangeType,
- FilePermission,
- FileSystemProviderCapabilities,
- FileSystemProviderErrorCode,
- FileType,
- IFileChange,
- IFileDeleteOptions,
- IFileOverwriteOptions,
- IFileSystemProviderWithFileReadWriteCapability,
- IFileWriteOptions,
- IStat,
- IWatchOptions,
-} from '../../../../platform/files/common/files.js';
-import { Extensions as JSONExtensions, IJSONContributionRegistry } from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js';
import { ILogService } from '../../../../platform/log/common/log.js';
-import { Registry } from '../../../../platform/registry/common/platform.js';
-import { SessionConfigPropertySchema, SessionConfigSchema } from '../../../../platform/agentHost/common/state/protocol/state.js';
import { ResolveSessionConfigResult } from '../../../../platform/agentHost/common/state/protocol/commands.js';
-import { ISession, toSessionId } from '../../../services/sessions/common/session.js';
+import { SessionConfigPropertySchema } from '../../../../platform/agentHost/common/state/protocol/state.js';
+import { IAgentHostSessionsProvider } from '../../../common/agentHostSessionsProvider.js';
import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';
-import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js';
-import { ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js';
+import { ISession, toSessionId } from '../../../services/sessions/common/session.js';
+import {
+ AbstractAgentHostConfigFileSystemProvider,
+ AbstractAgentHostConfigSchemaRegistrar,
+ AgentHostConfigPropertyFilter,
+ buildAgentHostConfigJsonSchema,
+ IAgentHostConfigLike,
+ IAgentHostSettingsContext,
+ IAgentHostSettingsLocale,
+ serializeAgentHostConfigDocument,
+} from './agentHostSettingsShared.js';
/** Scheme for the synthetic agent-host session settings files. */
export const AGENT_SESSION_SETTINGS_SCHEME = 'agent-session-settings';
@@ -44,8 +33,8 @@ export const AGENT_SESSION_SETTINGS_SCHEME = 'agent-session-settings';
* URI shape: `agent-session-settings://{providerId}/{resourceScheme}{resourcePath}.jsonc`
*
* - `authority` = {@link ISession.providerId} (e.g. `local-agent-host`, `agenthost-`)
- * - path encodes the session's resource scheme and path so {@link parseSettingsUri}
- * can reconstruct the full {@link ISession.sessionId} via {@link toSessionId}
+ * - path encodes the session's resource scheme and path so the URI can be
+ * parsed back into an {@link ISession.sessionId} via {@link toSessionId}
* without having to look the session up on the provider.
*/
export function agentSessionSettingsUri(session: ISession): URI {
@@ -57,13 +46,12 @@ export function agentSessionSettingsUri(session: ISession): URI {
});
}
-interface IParsedSettingsUri {
- readonly providerId: string;
+interface ISessionSettingsContext extends IAgentHostSettingsContext {
/** Reconstructed {@link ISession.sessionId}. */
readonly sessionId: string;
}
-function parseSettingsUri(uri: URI): IParsedSettingsUri | undefined {
+function parseSessionSettingsUri(uri: URI): ISessionSettingsContext | undefined {
if (uri.scheme !== AGENT_SESSION_SETTINGS_SCHEME) {
return undefined;
}
@@ -90,434 +78,148 @@ function parseSettingsUri(uri: URI): IParsedSettingsUri | undefined {
return { providerId, sessionId: toSessionId(providerId, resource) };
}
+/**
+ * Property filter: only session-mutable, non-read-only properties are
+ * editable. Read-only / non-mutable properties (e.g. `isolation`, `branch`)
+ * are preserved in the underlying config and round-tripped on write — they
+ * just aren't surfaced for editing.
+ */
+const sessionSettingsPropertyFilter: AgentHostConfigPropertyFilter = (_key, schema) => {
+ const s = schema as SessionConfigPropertySchema;
+ return s.sessionMutable === true && s.readOnly !== true;
+};
+
+const sessionSettingsLocale: IAgentHostSettingsLocale = {
+ get header() { return localize('agentSessionSettings.header', "Session settings for this agent host session."); },
+ get saveHint() { return localize('agentSessionSettings.saveHint', "Edit values below and save to apply. Unknown or non-mutable properties are ignored."); },
+ get parseError() { return localize('agentSessionSettings.parseError', "Failed to parse agent session settings as JSON."); },
+ get notObject() { return localize('agentSessionSettings.notObject', "Agent session settings must be a JSON object."); },
+};
+
/**
* Serialize the session-mutable config values for a session into a
* commented, pretty-printed JSON document.
*/
export function serializeSessionSettings(provider: IAgentHostSessionsProvider, sessionId: string): string {
- const config = provider.getSessionConfig(sessionId);
- if (!config) {
- return `${headerComment(undefined)}{}\n`;
- }
-
- // Only include session-mutable, non-readOnly properties in the editable
- // document. Read-only / non-mutable properties (e.g. `isolation`, `branch`)
- // are preserved in the underlying config and round-tripped on write —
- // they just aren't surfaced for editing.
- const mutableProps = Object.entries(config.schema.properties).filter(([, schema]) => schema.sessionMutable && !schema.readOnly);
- const values: Record = {};
- for (const [key] of mutableProps) {
- if (config.values[key] !== undefined) {
- values[key] = config.values[key];
- }
- }
-
- return `${headerComment(mutableProps)}${JSON.stringify(values, null, 2)}\n`;
+ return serializeAgentHostConfigDocument(provider.getSessionConfig(sessionId), sessionSettingsPropertyFilter, sessionSettingsLocale);
}
-function headerComment(props: readonly (readonly [string, { readonly title: string; readonly description?: string; readonly enum?: readonly string[] }])[] | undefined): string {
- const lines: string[] = [];
- lines.push(`// ${localize('agentSessionSettings.header', "Session settings for this agent host session.")}`);
- lines.push(`// ${localize('agentSessionSettings.saveHint', "Edit values below and save to apply. Unknown or non-mutable properties are ignored.")}`);
- if (props && props.length > 0) {
- lines.push('//');
- for (const [key, schema] of props) {
- const suffix = schema.enum && schema.enum.length > 0 ? ` (${schema.enum.join(' | ')})` : '';
- const title = schema.title || key;
- lines.push(`// ${key}: ${title}${suffix}`);
- if (schema.description) {
- lines.push(`// ${schema.description}`);
- }
- }
- }
- lines.push('');
- return lines.join('\n');
+/**
+ * Build a JSON schema describing the editable session-mutable, non-readOnly
+ * properties of an agent-host session config. The filter mirrors the one
+ * used by {@link serializeSessionSettings} so validation matches the file
+ * contents produced by this provider.
+ */
+export function buildSessionSettingsJsonSchema(config: ResolveSessionConfigResult): IJSONSchema {
+ return buildAgentHostConfigJsonSchema(config, sessionSettingsPropertyFilter);
}
/**
* Filesystem provider serving synthetic JSONC documents that represent the
* session-mutable config values of agent-host sessions.
- *
- * Reads render `IAgentHostSessionsProvider.getSessionConfig()` as pretty
- * JSONC. Writes parse the document with the JSONC parser and push the user's
- * full editable view to `replaceSessionConfig`, which atomically replaces
- * user-editable values while preserving non-mutable / readOnly properties
- * (e.g. `isolation`, `branch`) server-side.
*/
-export class AgentSessionSettingsFileSystemProvider extends Disposable implements IFileSystemProviderWithFileReadWriteCapability {
-
- readonly capabilities = FileSystemProviderCapabilities.FileReadWrite | FileSystemProviderCapabilities.PathCaseSensitive;
-
- private readonly _onDidChangeCapabilities = this._register(new Emitter());
- readonly onDidChangeCapabilities = this._onDidChangeCapabilities.event;
+export class AgentSessionSettingsFileSystemProvider extends AbstractAgentHostConfigFileSystemProvider {
- private readonly _onDidChangeFile = this._register(new Emitter());
- readonly onDidChangeFile = this._onDidChangeFile.event;
+ protected readonly _schemeLabel = AGENT_SESSION_SETTINGS_SCHEME;
+ protected readonly _traceTag = 'AgentSessionSettings';
+ protected readonly _locale = sessionSettingsLocale;
constructor(
private readonly _schemaRegistrar: AgentSessionSettingsSchemaRegistrar,
- @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService,
- @ILogService private readonly _logService: ILogService,
+ @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService,
+ @ILogService logService: ILogService,
) {
- super();
+ super(sessionsProvidersService, logService);
}
- watch(resource: URI, _opts: IWatchOptions): IDisposable {
- // The underlying provider fires `onDidChangeSessionConfig` with a sessionId;
- // forward those into `onDidChangeFile` for the watched resource.
- const parsed = parseSettingsUri(resource);
- if (!parsed) {
- return Disposable.None;
- }
- const provider = this._lookupProvider(parsed.providerId);
- if (!provider) {
- return Disposable.None;
- }
- return provider.onDidChangeSessionConfig(changedSessionId => {
- if (changedSessionId === parsed.sessionId) {
- this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]);
- }
- });
+ protected _parseUri(resource: URI): ISessionSettingsContext | undefined {
+ return parseSessionSettingsUri(resource);
}
- async stat(resource: URI): Promise {
- const parsed = parseSettingsUri(resource);
- if (!parsed) {
- throw createFileSystemProviderError(`Invalid agent-session-settings URI: ${resource.toString()}`, FileSystemProviderErrorCode.FileNotFound);
- }
- const { provider, sessionId } = this._resolve(parsed);
- const content = serializeSessionSettings(provider, sessionId);
- return {
- type: FileType.File,
- ctime: 0,
- mtime: 0,
- size: VSBuffer.fromString(content).byteLength,
- permissions: 0 as FilePermission,
- };
+ protected _serialize(provider: IAgentHostSessionsProvider, ctx: ISessionSettingsContext): string {
+ return serializeSessionSettings(provider, ctx.sessionId);
}
- async readdir(): Promise<[string, FileType][]> {
- throw createFileSystemProviderError('readdir not supported', FileSystemProviderErrorCode.NoPermissions);
+ protected _watchChanges(provider: IAgentHostSessionsProvider, ctx: ISessionSettingsContext, fire: () => void): IDisposable {
+ return provider.onDidChangeSessionConfig(changedSessionId => {
+ if (changedSessionId === ctx.sessionId) {
+ fire();
+ }
+ });
}
- async readFile(resource: URI): Promise {
- const parsed = parseSettingsUri(resource);
- if (!parsed) {
- throw createFileSystemProviderError(`Invalid agent-session-settings URI: ${resource.toString()}`, FileSystemProviderErrorCode.FileNotFound);
- }
- const { provider, sessionId } = this._resolve(parsed);
- const content = serializeSessionSettings(provider, sessionId);
-
- // Register the JSON schema on demand the first time a settings file
- // is read. The registrar keeps it in sync from then on.
- const session = provider.getSessions().find(s => s.sessionId === sessionId);
+ protected _ensureSchemaRegistered(provider: IAgentHostSessionsProvider, ctx: ISessionSettingsContext): void {
+ const session = provider.getSessions().find(s => s.sessionId === ctx.sessionId);
if (session) {
- this._schemaRegistrar.ensureRegistered(session);
- }
-
- return VSBuffer.fromString(content).buffer;
- }
-
- async writeFile(resource: URI, content: Uint8Array, _opts: IFileWriteOptions): Promise {
- const parsed = parseSettingsUri(resource);
- if (!parsed) {
- throw createFileSystemProviderError(`Invalid agent-session-settings URI: ${resource.toString()}`, FileSystemProviderErrorCode.FileNotFound);
- }
- const { provider, sessionId } = this._resolve(parsed);
-
- const text = VSBuffer.wrap(content).toString();
- const errors: ParseError[] = [];
- const parsed_json = parse(text, errors);
- if (errors.length > 0) {
- throw createFileSystemProviderError(
- localize('agentSessionSettings.parseError', "Failed to parse agent session settings as JSON."),
- FileSystemProviderErrorCode.Unavailable,
- );
- }
- if (parsed_json === null || typeof parsed_json !== 'object' || Array.isArray(parsed_json)) {
- throw createFileSystemProviderError(
- localize('agentSessionSettings.notObject', "Agent session settings must be a JSON object."),
- FileSystemProviderErrorCode.Unavailable,
- );
+ this._schemaRegistrar.ensureRegistered(provider, session);
}
-
- const currentConfig = provider.getSessionConfig(sessionId);
- if (!currentConfig) {
- this._logService.trace(`[AgentSessionSettings] No config state for session ${sessionId}; ignoring write.`);
- this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]);
- return;
- }
-
- // The input is the user's full view of editable values. Dispatch as a
- // replace — `replaceSessionConfig` guarantees non-editable properties
- // (non-mutable or readOnly) are preserved regardless of what we send,
- // and unknown keys are ignored. This means:
- // - Re-asserted editable keys overwrite the current value.
- // - Omitted editable keys are unset (supports clearing via deletion).
- // - Non-editable keys (e.g. `isolation`, `branch`) are round-tripped
- // server-side even though we never read or write them here.
- await provider.replaceSessionConfig(sessionId, parsed_json as Record);
-
- this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]);
- }
-
- async mkdir(): Promise {
- throw createFileSystemProviderError('mkdir not supported', FileSystemProviderErrorCode.NoPermissions);
}
- async delete(_resource: URI, _opts: IFileDeleteOptions): Promise {
- throw createFileSystemProviderError('delete not supported', FileSystemProviderErrorCode.NoPermissions);
+ protected _hasConfig(provider: IAgentHostSessionsProvider, ctx: ISessionSettingsContext): boolean {
+ return provider.getSessionConfig(ctx.sessionId) !== undefined;
}
- async rename(_from: URI, _to: URI, _opts: IFileOverwriteOptions): Promise {
- throw createFileSystemProviderError('rename not supported', FileSystemProviderErrorCode.NoPermissions);
- }
-
- // ---- Helpers ------------------------------------------------------------
-
- private _lookupProvider(providerId: string): IAgentHostSessionsProvider | undefined {
- const provider = this._sessionsProvidersService.getProvider(providerId);
- if (!provider || !isAgentHostProvider(provider)) {
- return undefined;
- }
- return provider;
+ // The input is the user's full view of editable values. Dispatch as a
+ // replace — `replaceSessionConfig` guarantees non-editable properties
+ // (non-mutable or readOnly) are preserved regardless of what we send,
+ // and unknown keys are ignored.
+ protected _replaceConfig(provider: IAgentHostSessionsProvider, ctx: ISessionSettingsContext, values: Record): Promise {
+ return provider.replaceSessionConfig(ctx.sessionId, values);
}
- private _resolve(parsed: IParsedSettingsUri): { provider: IAgentHostSessionsProvider; sessionId: string } {
- const provider = this._lookupProvider(parsed.providerId);
- if (!provider) {
- throw createFileSystemProviderError(
- `Unknown agent host provider: ${parsed.providerId}`,
- FileSystemProviderErrorCode.FileNotFound,
- );
- }
- return { provider, sessionId: parsed.sessionId };
+ protected _describeForTrace(ctx: ISessionSettingsContext): string {
+ return `session ${ctx.sessionId}`;
}
}
/**
- * Convert a session config property schema (protocol shape) into an
- * {@link IJSONSchema} suitable for registration with the JSON language
- * service.
- */
-function convertPropertySchema(schema: SessionConfigPropertySchema): IJSONSchema {
- const out: IJSONSchema = {
- type: schema.type,
- title: schema.title,
- description: schema.description,
- default: schema.default,
- };
- if (schema.enum && schema.enum.length > 0) {
- out.enum = [...schema.enum];
- if (schema.enumDescriptions && schema.enumDescriptions.length > 0) {
- out.enumDescriptions = [...schema.enumDescriptions];
- }
- }
- if (schema.type === 'array' && schema.items) {
- out.items = convertPropertySchema(schema.items);
- }
- if (schema.type === 'object' && schema.properties) {
- const properties: Record = {};
- for (const [key, value] of Object.entries(schema.properties)) {
- properties[key] = convertPropertySchema(value);
- }
- out.properties = properties;
- if (schema.required && schema.required.length > 0) {
- out.required = [...schema.required];
- }
- }
- return out;
-}
-
-/**
- * Build a JSON schema describing the editable session-mutable, non-readOnly
- * properties of an agent-host session config. The filter mirrors the one in
- * {@link serializeSessionSettings} so validation matches the file contents
- * produced by this provider.
- */
-export function buildSessionSettingsJsonSchema(config: ResolveSessionConfigResult): IJSONSchema {
- const properties: Record = {};
- const required: string[] = [];
- for (const [key, schema] of Object.entries(config.schema.properties)) {
- if (!schema.sessionMutable || schema.readOnly) {
- continue;
- }
- properties[key] = convertPropertySchema(schema);
- if (config.schema.required?.includes(key)) {
- required.push(key);
- }
- }
- const result: IJSONSchema = {
- type: 'object',
- properties,
- additionalProperties: true,
- };
- if (required.length > 0) {
- result.required = required;
- }
- return result;
-}
-
-/**
- * Keeps per-session JSON schemas registered on the
- * {@link IJSONContributionRegistry} so editors of the synthetic
+ * Keeps per-session JSON schemas registered so editors of the synthetic
* `agent-session-settings://…` files get completions, hover, and validation.
- *
- * Registration is lazy — {@link ensureRegistered} is called by
- * {@link AgentSessionSettingsFileSystemProvider.readFile} the first time a
- * session's settings document is read, so we avoid the JSON language
- * service overhead for sessions that are never opened. Once registered, the
- * schema is kept in sync via `onDidChangeSessionConfig` until the session
- * or its provider is removed.
- *
- * A schema is rebuilt only when the session's underlying
- * {@link SessionConfigSchema} changes by identity (protocol config schemas
- * are treated as immutable snapshots); value-only changes are ignored to
- * avoid churning the JSON language service.
*/
-export class AgentSessionSettingsSchemaRegistrar extends Disposable {
+export class AgentSessionSettingsSchemaRegistrar extends AbstractAgentHostConfigSchemaRegistrar {
- private readonly _schemaRegistry = Registry.as(JSONExtensions.JSONContribution);
-
- /** Per-provider subscriptions (session listeners, config listeners). */
- private readonly _providerSubscriptions = this._register(new DisposableMap());
+ protected _propertyFilter(): AgentHostConfigPropertyFilter {
+ return sessionSettingsPropertyFilter;
+ }
- /** Per-session registered-schema disposables, keyed by the settings URI string. */
- private readonly _sessionSchemas = this._register(new DisposableMap());
+ protected _settingsUri(session: ISession): string {
+ return agentSessionSettingsUri(session).toString();
+ }
- /**
- * Tracks the {@link SessionConfigSchema} identity last used to register
- * a schema for a given settings URI, so we can skip re-registration when
- * only values have changed.
- */
- private readonly _lastSchemaIdentity = new Map();
+ // Schema content is served via the `vscode://schemas/...` filesystem
+ // provider (see `SettingsFileSystemProvider`); the JSON language client
+ // only knows how to fetch schema content for that scheme. The
+ // settings-file URI is used as the fileMatch glob so the schema is
+ // applied to the actual editor document.
+ protected _schemaId(session: ISession): string {
+ return `vscode://schemas/agent-session-settings/${session.providerId}/${session.resource.scheme}/${session.resource.path}.jsonc`;
+ }
- constructor(
- @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService,
- ) {
- super();
+ protected _getConfig(provider: IAgentHostSessionsProvider, session: ISession): IAgentHostConfigLike | undefined {
+ return provider.getSessionConfig(session.sessionId);
+ }
- for (const provider of this._sessionsProvidersService.getProviders()) {
- this._onProviderAdded(provider);
- }
- this._register(this._sessionsProvidersService.onDidChangeProviders(e => {
- for (const provider of e.added) {
- this._onProviderAdded(provider);
- }
- for (const provider of e.removed) {
- this._onProviderRemoved(provider);
- }
- }));
+ protected _targetsForProvider(provider: IAgentHostSessionsProvider): readonly ISession[] {
+ return provider.getSessions();
}
- private _onProviderAdded(provider: ISessionsProvider): void {
- if (!isAgentHostProvider(provider)) {
- return;
- }
+ protected _observeProvider(
+ provider: IAgentHostSessionsProvider,
+ onChanged: (session: ISession) => void,
+ onRemoved: (session: ISession) => void,
+ ): IDisposable {
const store = new DisposableStore();
-
- // Note: we do NOT seed schemas eagerly here — registration is lazy and
- // only happens on the first `readFile` for a given session via
- // {@link ensureRegistered}. Registering schemas is relatively expensive
- // for the JSON language service, so we avoid paying that cost for
- // sessions whose settings files are never opened.
-
store.add(provider.onDidChangeSessionConfig(sessionId => {
- const schemaUri = this._schemaUriForSession(provider.id, sessionId);
- // Only refresh if we already have a registration; otherwise the
- // next `readFile` will pick up the latest schema on demand.
- if (!schemaUri || !this._lastSchemaIdentity.has(schemaUri)) {
- return;
- }
const session = provider.getSessions().find(s => s.sessionId === sessionId);
if (session) {
- this._refreshSchema(provider, session);
+ onChanged(session);
}
}));
-
store.add(provider.onDidChangeSessions(e => {
for (const removed of e.removed) {
- this._disposeSchema(removed);
- }
- }));
-
- // On provider disposal, drop all session schemas for this provider.
- store.add(toDisposable(() => {
- for (const session of provider.getSessions()) {
- this._disposeSchema(session);
+ onRemoved(removed);
}
}));
-
- this._providerSubscriptions.set(provider.id, store);
- }
-
- private _onProviderRemoved(provider: ISessionsProvider): void {
- this._providerSubscriptions.deleteAndDispose(provider.id);
- }
-
- /**
- * Ensures a JSON schema is registered for the given session. Called
- * lazily by the filesystem provider when a settings file is first read
- * so we avoid the cost of registering schemas for sessions that are
- * never opened.
- *
- * Once registered, the schema is kept in sync via
- * `onDidChangeSessionConfig` until the session or its provider is
- * removed.
- */
- ensureRegistered(session: ISession): void {
- const provider = this._sessionsProvidersService.getProvider(session.providerId);
- if (!provider || !isAgentHostProvider(provider)) {
- return;
- }
- this._refreshSchema(provider, session);
- }
-
- private _schemaUriForSession(providerId: string, sessionId: string): string | undefined {
- const provider = this._sessionsProvidersService.getProvider(providerId);
- if (!provider || !isAgentHostProvider(provider)) {
- return undefined;
- }
- const session = provider.getSessions().find(s => s.sessionId === sessionId);
- return session ? agentSessionSettingsUri(session).toString() : undefined;
- }
-
- private _refreshSchema(provider: IAgentHostSessionsProvider, session: ISession): void {
- const config = provider.getSessionConfig(session.sessionId);
- if (!config) {
- return;
- }
- const settingsUri = agentSessionSettingsUri(session).toString();
- // Schema content is served via the `vscode://schemas/...` filesystem
- // provider (see `SettingsFileSystemProvider`); the JSON language
- // client only knows how to fetch schema content for that scheme.
- // The settings-file URI is used as the fileMatch glob so the schema
- // is applied to the actual editor document.
- const schemaId = `vscode://schemas/agent-session-settings/${session.providerId}/${session.resource.scheme}/${session.resource.path}.jsonc`;
- const identity = config.schema;
- if (this._lastSchemaIdentity.get(settingsUri) === identity) {
- return;
- }
-
- const schema = buildSessionSettingsJsonSchema(config);
-
- // Dispose any prior registration first, otherwise the old cleanup
- // disposable would delete the freshly registered schema. Clear the
- // identity cache as a side effect so we always proceed to register.
- this._sessionSchemas.deleteAndDispose(settingsUri);
-
- const store = new DisposableStore();
- this._schemaRegistry.registerSchema(schemaId, schema, store);
- store.add(this._schemaRegistry.registerSchemaAssociation(schemaId, settingsUri));
- store.add(toDisposable(() => this._lastSchemaIdentity.delete(settingsUri)));
-
- this._sessionSchemas.set(settingsUri, store);
- this._lastSchemaIdentity.set(settingsUri, identity);
- }
-
- private _disposeSchema(session: ISession): void {
- const schemaUri = agentSessionSettingsUri(session).toString();
- this._sessionSchemas.deleteAndDispose(schemaUri);
+ return store;
}
}
-
diff --git a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts
index 2e6e07c72cdd1..3ef9840244325 100644
--- a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts
+++ b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts
@@ -17,7 +17,7 @@ import { localize } from '../../../../nls.js';
import { AgentSession, IAgentConnection, IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js';
import { ResolveSessionConfigResult } from '../../../../platform/agentHost/common/state/protocol/commands.js';
import { NotificationType } from '../../../../platform/agentHost/common/state/protocol/notifications.js';
-import { FileEdit, ModelSelection, RootState, SessionState, SessionSummary, SessionStatus as ProtocolSessionStatus } from '../../../../platform/agentHost/common/state/protocol/state.js';
+import { FileEdit, ModelSelection, RootConfigState, RootState, SessionState, SessionSummary, SessionStatus as ProtocolSessionStatus } from '../../../../platform/agentHost/common/state/protocol/state.js';
import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js';
import { StateComponents } from '../../../../platform/agentHost/common/state/sessionState.js';
import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js';
@@ -44,7 +44,7 @@ import { ISendRequestOptions, ISessionChangeEvent } from '../../../services/sess
*/
export interface IAgentHostAdapterOptions {
readonly icon: ThemeIcon;
- readonly description: IMarkdownString;
+ readonly description: IMarkdownString | undefined;
/** Loading observable wired to the provider's authentication-pending state. */
readonly loading: IObservable;
/** Builds the session workspace from session metadata; provider-specific (icon, providerLabel, requiresWorkspaceTrust). */
@@ -249,6 +249,12 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement
protected readonly _onDidChangeSessionConfig = this._register(new Emitter());
readonly onDidChangeSessionConfig = this._onDidChangeSessionConfig.event;
+ protected readonly _onDidChangeRootConfig = this._register(new Emitter());
+ readonly onDidChangeRootConfig = this._onDidChangeRootConfig.event;
+
+ /** Last-known root config state (schema + values), seeded from `RootState.config`. */
+ protected _rootConfig: RootConfigState | undefined;
+
/** Cache of adapted sessions, keyed by raw session ID. */
protected readonly _sessionCache = new Map();
@@ -359,6 +365,28 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement
this._onDidChangeSessionTypes.fire();
}
+ /**
+ * Reconcile {@link _rootConfig} against {@link RootState.config}, firing
+ * {@link onDidChangeRootConfig} only when schema or values actually change.
+ */
+ protected _syncRootConfigFromRootState(rootState: RootState): void {
+ const next = rootState.config;
+ const prev = this._rootConfig;
+ if (prev === next) {
+ return;
+ }
+ if (!next) {
+ this._rootConfig = undefined;
+ this._onDidChangeRootConfig.fire();
+ return;
+ }
+ if (prev && prev.schema === next.schema && equals(prev.values, next.values)) {
+ return;
+ }
+ this._rootConfig = next;
+ this._onDidChangeRootConfig.fire();
+ }
+
abstract resolveWorkspace(repositoryUri: URI): ISessionWorkspace | undefined;
/** Optional event fired when the underlying connection is lost; used to short-circuit `_waitForNewSession`. */
@@ -575,7 +603,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement
const nextValues: Record = {};
for (const [key, schema] of Object.entries(runningConfig.schema.properties)) {
const editable = schema.sessionMutable === true && schema.readOnly !== true;
- if (editable && Object.hasOwn(values, key)) {
+ if (editable) {
nextValues[key] = values[key];
} else if (Object.hasOwn(runningConfig.values, key)) {
nextValues[key] = runningConfig.values[key];
@@ -633,6 +661,67 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement
this._clearNewSessionConfig(sessionId);
}
+ // -- Root (agent host) Config --------------------------------------------
+
+ getRootConfig(): RootConfigState | undefined {
+ return this._rootConfig;
+ }
+
+ async setRootConfigValue(property: string, value: unknown): Promise {
+ const current = this._rootConfig;
+ const connection = this.connection;
+ if (!current || !connection) {
+ return;
+ }
+ if (!current.schema.properties[property]) {
+ return;
+ }
+
+ // Optimistically update local cache.
+ this._rootConfig = {
+ ...current,
+ values: { ...current.values, [property]: value },
+ };
+ this._onDidChangeRootConfig.fire();
+
+ const action = {
+ type: ActionType.RootConfigChanged as const,
+ config: { [property]: value },
+ };
+ connection.dispatch(action);
+ }
+
+ async replaceRootConfig(values: Record): Promise {
+ const current = this._rootConfig;
+ const connection = this.connection;
+ if (!current || !connection) {
+ return;
+ }
+
+ // Filter to known properties so we don't dispatch values for keys the
+ // host didn't publish a schema for.
+ const nextValues: Record = {};
+ for (const [key, value] of Object.entries(values)) {
+ if (current.schema.properties[key]) {
+ nextValues[key] = value;
+ }
+ }
+
+ if (equals(nextValues, current.values)) {
+ return;
+ }
+
+ this._rootConfig = { ...current, values: nextValues };
+ this._onDidChangeRootConfig.fire();
+
+ const action = {
+ type: ActionType.RootConfigChanged as const,
+ config: nextValues,
+ replace: true,
+ };
+ connection.dispatch(action);
+ }
+
// -- Model selection ------------------------------------------------------
setModel(sessionId: string, modelId: string): void {
diff --git a/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts
index eb9889aab987a..dfe47123109b0 100644
--- a/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts
+++ b/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts
@@ -71,9 +71,11 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide
const rootStateValue = this._agentHostService.rootState.value;
if (rootStateValue && !(rootStateValue instanceof Error)) {
this._syncSessionTypesFromRootState(rootStateValue);
+ this._syncRootConfigFromRootState(rootStateValue);
}
this._register(this._agentHostService.rootState.onDidChange(rootState => {
this._syncSessionTypesFromRootState(rootState);
+ this._syncRootConfigFromRootState(rootState);
}));
// Eagerly populate the session cache once authentication has settled.
diff --git a/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts
index 279685b9008d0..2aadbca6d3cae 100644
--- a/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts
+++ b/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts
@@ -14,7 +14,7 @@ import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelSc
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { AgentSession, IAgentHostService, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js';
import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js';
-import type { SessionAction, TerminalAction } from '../../../../../platform/agentHost/common/state/protocol/action-origin.generated.js';
+import type { RootAction, SessionAction, TerminalAction } from '../../../../../platform/agentHost/common/state/protocol/action-origin.generated.js';
import type { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js';
import { NotificationType } from '../../../../../platform/agentHost/common/state/protocol/notifications.js';
import { SessionLifecycle, type AgentInfo, type ModelSelection, type RootState, type SessionConfigState, type SessionState } from '../../../../../platform/agentHost/common/state/protocol/state.js';
@@ -47,7 +47,7 @@ class MockAgentHostService extends mock() {
override readonly clientId = 'test-local-client';
private readonly _sessions = new Map();
public disposedSessions: URI[] = [];
- public dispatchedActions: { action: SessionAction | TerminalAction; clientId: string; clientSeq: number }[] = [];
+ public dispatchedActions: { action: RootAction | SessionAction | TerminalAction; clientId: string; clientSeq: number }[] = [];
public failResolveSessionConfig = false;
public resolveSessionConfigResult: ResolveSessionConfigResult = { schema: { type: 'object', properties: {} }, values: { isolation: 'worktree' } };
@@ -93,11 +93,11 @@ class MockAgentHostService extends mock() {
return this.resolveSessionConfigResult;
}
- dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void {
+ dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void {
this.dispatchedActions.push({ action, clientId, clientSeq });
}
- override dispatch(action: SessionAction | TerminalAction): void {
+ override dispatch(action: RootAction | SessionAction | TerminalAction): void {
this.dispatchedActions.push({ action, clientId: this.clientId, clientSeq: this._nextSeq++ });
}
diff --git a/src/vs/sessions/contrib/browserView/browser/sessionBrowserView.contribution.ts b/src/vs/sessions/contrib/browserView/browser/sessionBrowserView.contribution.ts
new file mode 100644
index 0000000000000..6ca1ee8847a6c
--- /dev/null
+++ b/src/vs/sessions/contrib/browserView/browser/sessionBrowserView.contribution.ts
@@ -0,0 +1,9 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js';
+import { SessionBrowserViewController } from './sessionBrowserView.js';
+
+registerWorkbenchContribution2(SessionBrowserViewController.ID, SessionBrowserViewController, WorkbenchPhase.AfterRestored);
diff --git a/src/vs/sessions/contrib/browserView/browser/sessionBrowserView.ts b/src/vs/sessions/contrib/browserView/browser/sessionBrowserView.ts
new file mode 100644
index 0000000000000..4ccd44aca7474
--- /dev/null
+++ b/src/vs/sessions/contrib/browserView/browser/sessionBrowserView.ts
@@ -0,0 +1,106 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js';
+import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js';
+import { IBrowserViewWorkbenchService } from '../../../../workbench/contrib/browserView/common/browserView.js';
+import { BrowserEditorInput } from '../../../../workbench/contrib/browserView/common/browserEditorInput.js';
+import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js';
+import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js';
+import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
+import { ISession } from '../../../services/sessions/common/session.js';
+import { runOnChange } from '../../../../base/common/observable.js';
+
+export class SessionBrowserViewController extends Disposable implements IWorkbenchContribution {
+
+ static readonly ID = 'workbench.contrib.sessionBrowserViewController';
+
+ /**
+ * Tracks browser view inputs with their owning session. The
+ * DisposableMap cleans up lifecycle listeners on deletion/disposal.
+ */
+ private readonly _trackedInputs = this._register(new DisposableMap void }>());
+
+ constructor(
+ @ISessionsManagementService private readonly _sessionManagementService: ISessionsManagementService,
+ @IBrowserViewWorkbenchService private readonly _browserViewService: IBrowserViewWorkbenchService,
+ @IEditorService private readonly _editorService: IEditorService,
+ @IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService,
+ ) {
+ super();
+
+ // Catch editors opened via normal user/tool actions.
+ this._register(this._editorService.onWillOpenEditor(e => {
+ if (e.editor instanceof BrowserEditorInput) {
+ this._attachLifecycle(e.editor);
+ }
+ }));
+
+ // Catch editors restored from a working set swap — onWillOpenEditor
+ // does not fire for deserialized editors, but onDidAddGroup fires
+ // after the group (with its editors) has been created.
+ this._register(this._editorGroupsService.onDidAddGroup(group => {
+ for (const editor of group.editors) {
+ if (editor instanceof BrowserEditorInput) {
+ this._attachLifecycle(editor);
+ }
+ }
+ }));
+
+ // Force-destroy browser views when sessions are removed.
+ this._register(this._sessionManagementService.onDidChangeSessions(e => {
+ if (e.removed.length === 0 || this._trackedInputs.size === 0) {
+ return;
+ }
+
+ const removedSessionIds = new Set(e.removed.map(s => s.resource.toString()));
+ const known = this._browserViewService.getKnownBrowserViews();
+ for (const [id, { session }] of this._trackedInputs) {
+ if (removedSessionIds.has(session.resource.toString())) {
+ const existingInput = known.get(id);
+ if (existingInput instanceof BrowserEditorInput) {
+ existingInput.dispose(true);
+ }
+ }
+ }
+ }));
+ }
+
+ private _attachLifecycle(input: BrowserEditorInput): void {
+ if (this._trackedInputs.has(input.id)) {
+ return;
+ }
+
+ const session = this._sessionManagementService.activeSession.read(undefined);
+ if (!session) {
+ return; // no session, no lifecycle management needed
+ }
+
+ const store = new DisposableStore();
+ this._trackedInputs.set(input.id, { session, dispose: () => store.dispose() });
+
+ // When the owning session is archived, force-dispose the browser view.
+ store.add(runOnChange(session.isArchived, (isArchived) => {
+ if (isArchived) {
+ input.dispose(true);
+ }
+ }));
+
+ store.add(input.onBeforeDispose(e => {
+ const activeSession = this._sessionManagementService.activeSession.read(undefined);
+
+ // If the input is being disposed, but we are not currently in the owning session,
+ // assume a session swap is happening and do not actually dispose the browser yet.
+ if (session.sessionId !== activeSession?.sessionId) {
+ e.veto();
+ }
+ }));
+
+ store.add(input.onWillDispose(() => {
+ store.dispose();
+ this._trackedInputs.deleteAndDispose(input.id);
+ }));
+ }
+}
diff --git a/src/vs/sessions/contrib/changes/browser/changesTitleBarWidget.ts b/src/vs/sessions/contrib/changes/browser/changesTitleBarWidget.ts
index a57be3a52ecd9..63b7f89619467 100644
--- a/src/vs/sessions/contrib/changes/browser/changesTitleBarWidget.ts
+++ b/src/vs/sessions/contrib/changes/browser/changesTitleBarWidget.ts
@@ -56,7 +56,7 @@ export class ChangesTitleBarContribution extends Disposable implements IWorkbenc
},
},
group: 'navigation',
- order: 11, // After Run Script (8), Open in VS Code (9), and Open Terminal (10)
+ order: 11, // After Open in VS Code (7), Run Script (8), and Open Terminal (10)
when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()),
}));
}
diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts
index a4fe043454555..eeaeac4022403 100644
--- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts
+++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts
@@ -17,6 +17,7 @@ import { IsNewChatInSessionContext, IsNewChatSessionContext } from '../../../com
import { BranchChatSessionAction } from './branchChatSessionAction.js';
import { RunScriptContribution } from './runScriptAction.js';
import './nullInlineChatSessionService.js';
+import './openInVSCodeWidget.js';
import './nullChatTipService.js';
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js';
diff --git a/src/vs/sessions/contrib/chat/browser/media/openInVSCode.css b/src/vs/sessions/contrib/chat/browser/media/openInVSCode.css
new file mode 100644
index 0000000000000..0ca469df6b076
--- /dev/null
+++ b/src/vs/sessions/contrib/chat/browser/media/openInVSCode.css
@@ -0,0 +1,98 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+/* "Open in VS Code" titlebar widget — icon-only at rest, expands on hover/focus. */
+.monaco-workbench .open-in-vscode-titlebar-widget {
+ display: inline-flex;
+ align-items: center;
+ height: 22px;
+ padding: 0 4px;
+ margin: 0;
+ border-radius: 5px;
+ cursor: pointer;
+ color: var(--vscode-titleBar-activeForeground);
+ -webkit-app-region: no-drag;
+ white-space: nowrap;
+ position: relative;
+ touch-action: manipulation;
+}
+
+.monaco-workbench .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-icon {
+ width: 16px;
+ height: 16px;
+ flex: 0 0 auto;
+ /* Dev fallback: the VS Code shield logo bundled in the sessions media folder.
+ * In production builds the distro mixin overwrites
+ * vs/workbench/browser/media/code-icon.svg with the quality-branded icon;
+ * the per-quality rules below then take precedence. */
+ background-image: url('./vscode-icon.svg');
+ background-repeat: no-repeat;
+ background-position: center center;
+ background-size: contain;
+}
+
+/* In production builds vscode-distro overlays vs/workbench/browser/media/code-icon.svg
+ * with the quality-specific branded VS Code icon. Use it whenever the product quality is
+ * known (the data-product-quality attribute is only set in non-dev builds). */
+.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="stable"] > .open-in-vscode-titlebar-widget-icon,
+.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="insider"] > .open-in-vscode-titlebar-widget-icon,
+.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="exploration"] > .open-in-vscode-titlebar-widget-icon {
+ background-image: url('../../../../../workbench/browser/media/code-icon.svg');
+}
+
+.monaco-workbench .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-label {
+ display: inline-block;
+ max-width: 0;
+ opacity: 0;
+ margin-left: 0;
+ color: var(--vscode-foreground);
+ font: inherit;
+ overflow: hidden;
+ white-space: nowrap;
+}
+
+.monaco-enable-motion .monaco-workbench .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-label,
+.monaco-workbench.monaco-enable-motion .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-label {
+ transition: max-width 150ms ease, opacity 150ms ease, margin-left 150ms ease;
+}
+
+.monaco-reduce-motion .monaco-workbench .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-label,
+.monaco-workbench.monaco-reduce-motion .open-in-vscode-titlebar-widget > .open-in-vscode-titlebar-widget-label {
+ transition-duration: 0ms !important;
+}
+
+.monaco-workbench .open-in-vscode-titlebar-widget:hover,
+.monaco-workbench .open-in-vscode-titlebar-widget:focus-visible {
+ background-color: var(--vscode-toolbar-hoverBackground);
+ outline: none;
+}
+
+/* Quality-tinted hover/focus background — blue (stable), green (insider), orange (exploration). */
+.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="stable"]:hover,
+.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="stable"]:focus-visible {
+ background-color: rgba(0, 122, 204, 0.18);
+}
+
+.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="insider"]:hover,
+.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="insider"]:focus-visible {
+ background-color: rgba(36, 187, 26, 0.20);
+}
+
+.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="exploration"]:hover,
+.monaco-workbench .open-in-vscode-titlebar-widget[data-product-quality="exploration"]:focus-visible {
+ background-color: rgba(255, 140, 0, 0.22);
+}
+
+.monaco-workbench .open-in-vscode-titlebar-widget:hover > .open-in-vscode-titlebar-widget-label,
+.monaco-workbench .open-in-vscode-titlebar-widget:focus-visible > .open-in-vscode-titlebar-widget-label {
+ max-width: 200px;
+ opacity: 1;
+ margin-left: 6px;
+}
+
+.monaco-workbench .open-in-vscode-titlebar-widget:focus-visible {
+ outline: 1px solid var(--vscode-focusBorder);
+ outline-offset: -1px;
+}
diff --git a/src/vs/sessions/contrib/chat/browser/media/vscode-icon.svg b/src/vs/sessions/contrib/chat/browser/media/vscode-icon.svg
new file mode 100644
index 0000000000000..39ff8ec6d02c3
--- /dev/null
+++ b/src/vs/sessions/contrib/chat/browser/media/vscode-icon.svg
@@ -0,0 +1,52 @@
+
diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts
index 1d2c7cea31d39..3e2bcb734c0e1 100644
--- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts
+++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts
@@ -5,7 +5,7 @@
import './media/chatWidget.css';
import * as dom from '../../../../base/browser/dom.js';
-import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js';
+import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';
import { derived } from '../../../../base/common/observable.js';
import { isWeb } from '../../../../base/common/platform.js';
import { URI } from '../../../../base/common/uri.js';
@@ -36,6 +36,9 @@ class NewChatWidget extends Disposable {
private readonly _workspacePicker: WorkspacePicker;
private readonly _newChatInput: NewChatInputWidget;
+ /** Tracks an in-flight wait for a provider's session types to become available. */
+ private readonly _pendingSessionTypeWait = new MutableDisposable();
+
constructor(
@IInstantiationService private readonly instantiationService: IInstantiationService,
@ILogService private readonly logService: ILogService,
@@ -45,6 +48,7 @@ class NewChatWidget extends Disposable {
) {
super();
this._workspacePicker = this._register(this.instantiationService.createInstance(isWeb ? ScopedWorkspacePicker : WorkspacePicker));
+ this._register(this._pendingSessionTypeWait);
const canSendRequest = derived(reader => {
const session = this.sessionsManagementService.activeSession.read(reader);
@@ -95,21 +99,14 @@ class NewChatWidget extends Disposable {
this._newChatInput.render(chatWidgetContent, parent);
- // Create initial session — wait for providers if none registered yet.
+ // Create initial session for any workspace already selected at construct time.
+ // If the selection arrives later (provider registers asynchronously), the
+ // picker fires onDidSelectWorkspace and our listener handles it.
// Skip if an active session already exists (restored by openNewSessionView
// from a pending new session when navigating back from another session).
const restoredProject = this._workspacePicker.selectedProject;
if (!this._syncWorkspacePickerFromActiveSession() && restoredProject) {
- if (this.sessionsProvidersService.getProviders().length > 0) {
- this._createNewSession(restoredProject, this._newChatInput.sessionTypePicker.selectedType);
- } else {
- // Providers not yet registered (startup race) — wait for first registration
- const sub = this.sessionsProvidersService.onDidChangeProviders(() => {
- sub.dispose();
- this._createNewSession(restoredProject, this._newChatInput.sessionTypePicker.selectedType);
- });
- this._register(sub);
- }
+ this._createNewSession(restoredProject, this._newChatInput.sessionTypePicker.selectedType);
}
chatWidgetContainer.classList.add('revealed');
@@ -143,7 +140,42 @@ class NewChatWidget extends Disposable {
}
private _createNewSession(selection: IWorkspaceSelection, sessionTypeId: string | undefined): void {
- this.sessionsManagementService.createNewSession(selection.providerId, selection.workspace.repositories[0].uri, sessionTypeId);
+ const provider = this.sessionsProvidersService.getProviders().find(p => p.id === selection.providerId);
+ const repoUri = selection.workspace.repositories[0].uri;
+
+ // Drop the carried-over sessionTypeId if it doesn't apply to this provider —
+ // happens when the picker upgrades to a different provider after restore and
+ // the previous active session's type (e.g. EH CLI's "agents") doesn't exist
+ // on the new provider (e.g. agent host).
+ if (sessionTypeId && provider && !provider.getSessionTypes(repoUri).some(t => t.id === sessionTypeId)) {
+ sessionTypeId = undefined;
+ }
+
+ // Session types may not be available yet (e.g., agent host still connecting).
+ // If so, wait for them before creating the session — otherwise createNewSession
+ // throws and the new chat view is left without an active session, which hides
+ // agent-host-specific UI (model picker etc.) until the user re-picks the workspace.
+ // If the connection fails, the picker fires onDidSelectWorkspace(undefined) which
+ // clears the pending wait via _onWorkspaceSelected.
+ if (provider && !sessionTypeId && provider.getSessionTypes(repoUri).length === 0 && provider.onDidChangeSessionTypes) {
+ const pendingStore = new DisposableStore();
+ this._pendingSessionTypeWait.value = pendingStore;
+
+ pendingStore.add(provider.onDidChangeSessionTypes(() => {
+ if (provider.getSessionTypes(repoUri).length > 0) {
+ this._pendingSessionTypeWait.clear();
+ this._createNewSession(selection, sessionTypeId);
+ }
+ }));
+
+ return;
+ }
+
+ try {
+ this.sessionsManagementService.createNewSession(selection.providerId, repoUri, sessionTypeId);
+ } catch (e) {
+ this.logService.error('Failed to create new session:', e);
+ }
}
/**
@@ -210,6 +242,9 @@ class NewChatWidget extends Disposable {
* Requests folder trust if needed and creates a new session.
*/
private async _onWorkspaceSelected(selection: IWorkspaceSelection | undefined, sessionTypeId: string | undefined): Promise {
+ // Cancel any in-flight wait for a previous selection.
+ this._pendingSessionTypeWait.clear();
+
if (!selection) {
this.sessionsManagementService.unsetNewSession();
return;
diff --git a/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts b/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts
index 0dd1cb31396db..652d45c9f348d 100644
--- a/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts
+++ b/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts
@@ -41,7 +41,7 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 {
menu: [{
id: Menus.TitleBarSessionMenu,
group: 'navigation',
- order: 9,
+ order: 7,
when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsPhoneLayoutContext.negate()),
}]
});
diff --git a/src/vs/sessions/contrib/chat/browser/openInVSCodeWidget.ts b/src/vs/sessions/contrib/chat/browser/openInVSCodeWidget.ts
new file mode 100644
index 0000000000000..8f3defa9c9c4a
--- /dev/null
+++ b/src/vs/sessions/contrib/chat/browser/openInVSCodeWidget.ts
@@ -0,0 +1,87 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import './media/openInVSCode.css';
+import { $, append, EventHelper, EventLike } from '../../../../base/browser/dom.js';
+import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js';
+import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js';
+import { IAction } from '../../../../base/common/actions.js';
+import { Disposable } from '../../../../base/common/lifecycle.js';
+import { localize } from '../../../../nls.js';
+import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js';
+import { IHoverService } from '../../../../platform/hover/browser/hover.js';
+import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
+import { IProductService } from '../../../../platform/product/common/productService.js';
+import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js';
+import { Menus } from '../../../browser/menus.js';
+
+const OpenInVSCodeActionId = 'chat.openSessionWorktreeInVSCode';
+
+/**
+ * Renders the "Open in VS Code" titlebar entry as an icon-only button that
+ * expands to reveal a label on hover / keyboard focus.
+ */
+class OpenInVSCodeTitleBarWidget extends BaseActionViewItem {
+
+ constructor(
+ action: IAction,
+ options: IBaseActionViewItemOptions | undefined,
+ @IProductService private readonly productService: IProductService,
+ @IHoverService private readonly hoverService: IHoverService,
+ ) {
+ super(undefined, action, options);
+ }
+
+ override render(container: HTMLElement): void {
+ super.render(container);
+
+ container.classList.add('open-in-vscode-titlebar-widget');
+ container.setAttribute('role', 'button');
+
+ // Set quality attribute for quality-tinted hover styling and distro icon selection.
+ // Only set when quality is known so that the CSS fallback icon is used in dev builds.
+ const quality = this.productService.quality;
+ if (quality) {
+ container.setAttribute('data-product-quality', quality);
+ }
+
+ const label = this.action.label || localize('openInVSCodeLabel', "Open in VS Code");
+ container.setAttribute('aria-label', label);
+ this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), container, label));
+
+ const icon = append(container, $('span.open-in-vscode-titlebar-widget-icon'));
+ icon.setAttribute('aria-hidden', 'true');
+
+ const labelEl = append(container, $('span.open-in-vscode-titlebar-widget-label'));
+ labelEl.textContent = label;
+ }
+
+ override onClick(event: EventLike): void {
+ EventHelper.stop(event, true);
+ this.action.run();
+ }
+}
+
+/**
+ * Workbench contribution that registers the custom action view item for
+ * the "Open in VS Code" action in the sessions titlebar toolbar, replacing
+ * the default icon-only codicon with a rich expandable widget.
+ */
+class OpenInVSCodeWidgetContribution extends Disposable implements IWorkbenchContribution {
+
+ static readonly ID = 'workbench.contrib.openInVSCode.widget';
+
+ constructor(
+ @IActionViewItemService actionViewItemService: IActionViewItemService,
+ @IInstantiationService instantiationService: IInstantiationService,
+ ) {
+ super();
+ this._register(actionViewItemService.register(Menus.TitleBarSessionMenu, OpenInVSCodeActionId, (action, options) => {
+ return instantiationService.createInstance(OpenInVSCodeTitleBarWidget, action, options);
+ }, undefined));
+ }
+}
+
+registerWorkbenchContribution2(OpenInVSCodeWidgetContribution.ID, OpenInVSCodeWidgetContribution, WorkbenchPhase.BlockRestore);
diff --git a/src/vs/sessions/contrib/chat/browser/scopedWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/scopedWorkspacePicker.ts
index a1932e8538fce..3899e133ab0c0 100644
--- a/src/vs/sessions/contrib/chat/browser/scopedWorkspacePicker.ts
+++ b/src/vs/sessions/contrib/chat/browser/scopedWorkspacePicker.ts
@@ -19,7 +19,6 @@ import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uri
import { IOutputService } from '../../../../workbench/services/output/common/output.js';
import { IPreferencesService } from '../../../../workbench/services/preferences/common/preferences.js';
import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';
-import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
import { IAgentHostFilterService } from '../../remoteAgentHost/common/agentHostFilter.js';
import { IWorkspacePickerItem, IWorkspaceSelection, WorkspacePicker } from './sessionWorkspacePicker.js';
import { IWorkspacesService } from '../../../../platform/workspaces/common/workspaces.js';
@@ -41,7 +40,6 @@ export class ScopedWorkspacePicker extends WorkspacePicker {
@IStorageService storageService: IStorageService,
@IUriIdentityService uriIdentityService: IUriIdentityService,
@ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService,
- @ISessionsManagementService sessionsManagementService: ISessionsManagementService,
@IRemoteAgentHostService remoteAgentHostService: IRemoteAgentHostService,
@IQuickInputService quickInputService: IQuickInputService,
@IClipboardService clipboardService: IClipboardService,
@@ -59,7 +57,6 @@ export class ScopedWorkspacePicker extends WorkspacePicker {
storageService,
uriIdentityService,
sessionsProvidersService,
- sessionsManagementService,
remoteAgentHostService,
quickInputService,
clipboardService,
diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts
index 92a348ef5d37b..c649172a7ba14 100644
--- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts
+++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts
@@ -8,9 +8,11 @@ import * as touch from '../../../../base/browser/touch.js';
import { IAction, SubmenuAction, toAction } from '../../../../base/common/actions.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { Emitter, Event } from '../../../../base/common/event.js';
+import { disposableTimeout } from '../../../../base/common/async.js';
import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js';
import { URI, UriComponents } from '../../../../base/common/uri.js';
import { basename } from '../../../../base/common/resources.js';
+import { autorun } from '../../../../base/common/observable.js';
import { localize } from '../../../../nls.js';
import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js';
import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js';
@@ -26,12 +28,10 @@ import { IOutputService } from '../../../../workbench/services/output/common/out
import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js';
-import { autorun } from '../../../../base/common/observable.js';
import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
import { ThemeIcon } from '../../../../base/common/themables.js';
import { ISessionWorkspace, ISessionWorkspaceBrowseAction } from '../../../services/sessions/common/session.js';
import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';
-import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js';
import { COPILOT_PROVIDER_ID } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js';
import { IWorkspacesService, isRecentFolder } from '../../../../platform/workspaces/common/workspaces.js';
@@ -42,6 +42,14 @@ const STORAGE_KEY_RECENT_WORKSPACES = 'sessions.recentlyPickedWorkspaces';
const FILTER_THRESHOLD = 10;
const MAX_RECENT_WORKSPACES = 10;
+/**
+ * Grace period for a restored remote workspace's provider to reach Connected
+ * before we fall back to no selection. SSH tunnels typically connect within
+ * a couple seconds; if it hasn't connected by then, we'd rather show no
+ * selection than leave the user staring at an unreachable workspace.
+ */
+const RESTORE_CONNECT_GRACE_MS = 5000;
+
/**
* A workspace selection from the picker, pairing the workspace with its owning provider.
*/
@@ -86,9 +94,24 @@ export class WorkspacePicker extends Disposable {
private _selectedWorkspace: IWorkspaceSelection | undefined;
+ /**
+ * Set to `true` once the user has explicitly picked or cleared a workspace.
+ * Until then, late-arriving provider registrations are allowed to upgrade
+ * the current (auto-restored) selection to the user's stored "checked"
+ * entry. After the user has acted, providers coming and going never move
+ * the selection out from under them.
+ */
+ private _userHasPicked = false;
+
+ /**
+ * Watches the connection status of a restored remote workspace. Cleared when
+ * the user explicitly picks, when the connection succeeds, or when it fails
+ * and we fall back.
+ */
+ private readonly _connectionStatusWatch = this._register(new MutableDisposable());
+
private _triggerElement: HTMLElement | undefined;
private readonly _renderDisposables = this._register(new DisposableStore());
- private readonly _connectionStatusListener = this._register(new MutableDisposable());
/** Cached VS Code recent folder URIs, resolved lazily. */
private _vsCodeRecentFolderUris: URI[] = [];
@@ -102,7 +125,6 @@ export class WorkspacePicker extends Disposable {
@IStorageService private readonly storageService: IStorageService,
@IUriIdentityService private readonly uriIdentityService: IUriIdentityService,
@ISessionsProvidersService protected readonly sessionsProvidersService: ISessionsProvidersService,
- @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService,
@IRemoteAgentHostService private readonly remoteAgentHostService: IRemoteAgentHostService,
@IQuickInputService private readonly quickInputService: IQuickInputService,
@IClipboardService private readonly clipboardService: IClipboardService,
@@ -121,32 +143,37 @@ export class WorkspacePicker extends Disposable {
// Restore selected workspace from storage
this._selectedWorkspace = this._restoreSelectedWorkspace();
+ if (this._selectedWorkspace) {
+ this._watchForConnectionFailure(this._selectedWorkspace);
+ }
// React to provider registrations/removals: re-validate the current
- // selection and attempt to restore a stored workspace when none is active.
+ // selection, and if the user hasn't explicitly picked yet, re-restore
+ // from storage so we upgrade from any fallback to the user's actual
+ // stored selection once its provider arrives.
this._register(this.sessionsProvidersService.onDidChangeProviders(() => {
if (this._selectedWorkspace) {
- // Validate that the selected workspace's provider is still registered
const providers = this.sessionsProvidersService.getProviders();
if (!providers.some(p => p.id === this._selectedWorkspace!.providerId)) {
this._selectedWorkspace = undefined;
+ this._connectionStatusWatch.clear();
this._updateTriggerLabel();
+ this._onDidChangeSelection.fire();
+ this._onDidSelectWorkspace.fire(undefined);
}
}
- if (!this._selectedWorkspace) {
+ if (!this._userHasPicked) {
const restored = this._restoreSelectedWorkspace();
- if (restored) {
+ if (restored && !this._isSelectedWorkspace(restored)) {
this._selectedWorkspace = restored;
this._updateTriggerLabel();
this._onDidChangeSelection.fire();
this._onDidSelectWorkspace.fire(restored);
+ this._watchForConnectionFailure(restored);
}
}
- this._watchConnectionStatus();
}));
- this._watchConnectionStatus();
-
// Load VS Code recent folders eagerly and refresh on changes
this._loadVSCodeRecentFolders();
this._register(this.workspacesService.onDidChangeRecentlyOpened(() => this._loadVSCodeRecentFolders()));
@@ -256,6 +283,8 @@ export class WorkspacePicker extends Disposable {
*/
clearSelection(): void {
this.actionWidgetService.hide();
+ this._userHasPicked = true;
+ this._connectionStatusWatch.clear();
this._selectedWorkspace = undefined;
// Clear checked state from all recents
const recents = this._getStoredRecentWorkspaces();
@@ -275,6 +304,8 @@ export class WorkspacePicker extends Disposable {
}
private _selectProject(selection: IWorkspaceSelection, fireEvent = true): void {
+ this._userHasPicked = true;
+ this._connectionStatusWatch.clear();
this._selectedWorkspace = selection;
this._persistSelectedWorkspace(selection);
this._updateTriggerLabel();
@@ -304,18 +335,6 @@ export class WorkspacePicker extends Disposable {
}
}
- private _getActiveProviders(): import('../../../services/sessions/common/sessionsProvider.js').ISessionsProvider[] {
- const activeProviderId = this.sessionsManagementService.activeProviderId.get();
- const allProviders = this.sessionsProvidersService.getProviders();
- if (activeProviderId) {
- const active = allProviders.find(p => p.id === activeProviderId);
- if (active) {
- return [active];
- }
- }
- return allProviders;
- }
-
/**
* Collects browse actions from all registered providers.
*/
@@ -630,44 +649,6 @@ export class WorkspacePicker extends Disposable {
return provider.connectionStatus.get() !== RemoteAgentHostConnectionStatus.Connected;
}
- /**
- * Watch connection status observables from all remote providers.
- * When a remote disconnects, clear the selection if it belongs to that
- * provider. When a remote reconnects, try to restore a stored workspace.
- */
- private _watchConnectionStatus(): void {
- const remoteProviders = this.sessionsProvidersService.getProviders().filter(isAgentHostProvider).filter(p => p.connectionStatus !== undefined);
- if (remoteProviders.length === 0) {
- this._connectionStatusListener.clear();
- return;
- }
-
- this._connectionStatusListener.value = autorun(reader => {
- for (const provider of remoteProviders) {
- provider.connectionStatus!.read(reader);
- }
-
- // If the current selection belongs to an unavailable provider, clear it
- if (this._selectedWorkspace && this._isProviderUnavailable(this._selectedWorkspace.providerId)) {
- this._selectedWorkspace = undefined;
- this._updateTriggerLabel();
- this._onDidChangeSelection.fire();
- }
-
- // If no selection, try to restore the previously checked workspace
- // (only the checked entry, not any fallback, to avoid unexpected switches)
- if (!this._selectedWorkspace) {
- const restored = this._restoreCheckedWorkspace();
- if (restored) {
- this._selectedWorkspace = restored;
- this._updateTriggerLabel();
- this._onDidChangeSelection.fire();
- this._onDidSelectWorkspace.fire(restored);
- }
- }
- });
- }
-
protected _isSelectedWorkspace(selection: IWorkspaceSelection): boolean {
if (!this._selectedWorkspace) {
return false;
@@ -695,9 +676,12 @@ export class WorkspacePicker extends Disposable {
return checked;
}
- // Fall back to the first resolvable recent workspace from a connected provider
+ // Fall back to the first resolvable recent workspace from a connected provider.
+ // Fallbacks (vs. the user's explicit checked pick) require the provider
+ // to be ready: we don't want to silently land on, e.g., a disconnected
+ // remote workspace that the user never picked.
try {
- const providers = this._getActiveProviders();
+ const providers = this.sessionsProvidersService.getProviders();
const providerIds = new Set(providers.map(p => p.id));
const storedRecents = this._getStoredRecentWorkspaces();
@@ -722,12 +706,14 @@ export class WorkspacePicker extends Disposable {
/**
* Restore only the checked (previously selected) workspace if its provider
- * is currently available. Does not fall back to other workspaces.
- * Used by the connection status watcher to avoid unexpected workspace switches.
+ * is registered. The provider's connection status is intentionally NOT
+ * checked — we honor the user's explicit pick even if the remote is still
+ * connecting or currently disconnected. The trigger label reflects the
+ * connection state separately (spinner / grayed).
*/
private _restoreCheckedWorkspace(): IWorkspaceSelection | undefined {
try {
- const providers = this._getActiveProviders();
+ const providers = this.sessionsProvidersService.getProviders();
const providerIds = new Set(providers.map(p => p.id));
const storedRecents = this._getStoredRecentWorkspaces();
@@ -735,9 +721,6 @@ export class WorkspacePicker extends Disposable {
if (!stored.checked || !providerIds.has(stored.providerId)) {
continue;
}
- if (this._isProviderUnavailable(stored.providerId)) {
- continue;
- }
const uri = URI.revive(stored.uri);
const workspace = this.sessionsProvidersService.getProvider(stored.providerId)?.resolveWorkspace(uri);
if (workspace) {
@@ -750,6 +733,65 @@ export class WorkspacePicker extends Disposable {
}
}
+ /**
+ * When restoring a workspace whose provider isn't currently Connected,
+ * watch the connection status. Fires `onDidSelectWorkspace(undefined)`
+ * (which the view pane converts to `unsetNewSession()`) if:
+ * - the status transitions to Disconnected after we start watching, or
+ * - the status is still not Connected after a short grace period.
+ *
+ * The grace period covers a race: provider state can transition synchronously
+ * inside provider registration before our autorun's first read, so we may
+ * never observe an explicit Disconnected transition. The timer ensures we
+ * eventually fall back instead of leaving the picker showing an unreachable
+ * remote with no session.
+ *
+ * Has no effect once the user makes an explicit pick (`_userHasPicked`).
+ */
+ private _watchForConnectionFailure(selection: IWorkspaceSelection): void {
+ const provider = this.sessionsProvidersService.getProvider(selection.providerId);
+ if (!provider || !isAgentHostProvider(provider) || !provider.connectionStatus) {
+ return;
+ }
+ const connStatus = provider.connectionStatus;
+ if (connStatus.get() === RemoteAgentHostConnectionStatus.Connected) {
+ return;
+ }
+
+ const store = new DisposableStore();
+ this._connectionStatusWatch.value = store;
+
+ const fallback = () => {
+ this._connectionStatusWatch.clear();
+ if (!this._userHasPicked && this._isSelectedWorkspace(selection)) {
+ this._selectedWorkspace = undefined;
+ this._updateTriggerLabel();
+ this._onDidChangeSelection.fire();
+ this._onDidSelectWorkspace.fire(undefined);
+ }
+ };
+
+ let isFirstRun = true;
+ store.add(autorun(reader => {
+ const status = connStatus.read(reader);
+ if (status === RemoteAgentHostConnectionStatus.Connected) {
+ this._connectionStatusWatch.clear();
+ } else if (status === RemoteAgentHostConnectionStatus.Disconnected && !isFirstRun) {
+ fallback();
+ }
+ isFirstRun = false;
+ }));
+
+ // Safety net: if the connection hasn't succeeded by the grace period,
+ // fall back. Catches the case where the provider's status flips before
+ // our autorun subscribes (so we never observe a transition).
+ disposableTimeout(() => {
+ if (connStatus.get() !== RemoteAgentHostConnectionStatus.Connected) {
+ fallback();
+ }
+ }, RESTORE_CONNECT_GRACE_MS, store);
+ }
+
/**
* Migrate legacy `sessions.recentlyPickedProjects` storage to the new
* `sessions.recentlyPickedWorkspaces` key, adding `providerId` (defaulting
diff --git a/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts
index b78163b6860ed..975d1a0112319 100644
--- a/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts
+++ b/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts
@@ -13,6 +13,8 @@ import { AGENT_HOST_SCHEME, fromAgentHostUri } from '../../../../platform/agentH
import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js';
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
import { INativeHostService } from '../../../../platform/native/common/native.js';
+import { IOpenerService } from '../../../../platform/opener/common/opener.js';
+import { IProductService } from '../../../../platform/product/common/productService.js';
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js';
import { IsPhoneLayoutContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js';
@@ -23,13 +25,17 @@ import { ISessionsManagementService } from '../../../services/sessions/common/se
import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';
import { resolveRemoteAuthority } from '../browser/openInVSCodeUtils.js';
import { DebugAgentHostInDevToolsAction } from '../../../../workbench/contrib/chat/electron-browser/actions/debugAgentHostAction.js';
+import { isLinux } from '../../../../base/common/platform.js';
+import { IEnvironmentService } from '../../../../platform/environment/common/environment.js';
/**
* Desktop version of the "Open in VS Code" action.
*
- * Launches the host VS Code app via {@link INativeHostService.launchSiblingApp}
- * (child_process.spawn) with direct CLI arguments, bypassing protocol handlers
- * and their OS security prompts.
+ * In built builds with a sibling app configured, launches the host VS Code app
+ * via {@link INativeHostService.launchSiblingApp} (child_process.spawn) with
+ * direct CLI arguments, bypassing protocol handlers and their OS security
+ * prompts. In dev builds (no sibling app), falls back to the protocol handler
+ * approach via {@link IOpenerService}.
*/
registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 {
static readonly ID = 'chat.openSessionWorktreeInVSCode';
@@ -43,7 +49,7 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 {
menu: [{
id: Menus.TitleBarSessionMenu,
group: 'navigation',
- order: 9,
+ order: 7,
when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsPhoneLayoutContext.negate()),
}]
});
@@ -53,7 +59,8 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 {
const telemetryService = accessor.get(ITelemetryService);
logSessionsInteraction(telemetryService, 'openInVSCode');
- const nativeHostService = accessor.get(INativeHostService);
+ const productService = accessor.get(IProductService);
+ const environmentService = accessor.get(IEnvironmentService);
const sessionsManagementService = accessor.get(ISessionsManagementService);
const sessionsProvidersService = accessor.get(ISessionsProvidersService);
const remoteAgentHostService = accessor.get(IRemoteAgentHostService);
@@ -67,6 +74,21 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 {
? resolveRemoteAuthority(activeSession.providerId, sessionsProvidersService, remoteAgentHostService)
: undefined;
+ if (environmentService.isBuilt && !isLinux) {
+ await this.launchViaSiblingApp(accessor, activeSession, folderUri, remoteAuthority);
+ } else {
+ await this.launchViaProtocolHandler(accessor, productService, activeSession, folderUri, remoteAuthority);
+ }
+ }
+
+ private async launchViaSiblingApp(
+ accessor: ServicesAccessor,
+ activeSession: ReturnType,
+ folderUri: URI | undefined,
+ remoteAuthority: string | undefined,
+ ): Promise {
+ const nativeHostService = accessor.get(INativeHostService);
+
const args: string[] = ['--new-window'];
if (folderUri) {
@@ -83,6 +105,50 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 {
await nativeHostService.launchSiblingApp(args);
}
+
+ private async launchViaProtocolHandler(
+ accessor: ServicesAccessor,
+ productService: IProductService,
+ activeSession: ReturnType,
+ folderUri: URI | undefined,
+ remoteAuthority: string | undefined,
+ ): Promise {
+ const openerService = accessor.get(IOpenerService);
+
+ const scheme = productService.quality === 'stable'
+ ? 'vscode'
+ : productService.quality === 'exploration'
+ ? 'vscode-exploration'
+ : productService.quality === 'insider'
+ ? 'vscode-insiders'
+ : productService.urlProtocol;
+
+ const params = new URLSearchParams();
+ params.set('windowId', '_blank');
+
+ if (!activeSession || !folderUri) {
+ await openerService.open(URI.from({ scheme, query: params.toString() }), { openExternal: true });
+ return;
+ }
+
+ params.set('session', activeSession.resource.toString());
+
+ if (remoteAuthority) {
+ await openerService.open(URI.from({
+ scheme,
+ authority: Schemas.vscodeRemote,
+ path: `/${remoteAuthority}${folderUri.path}`,
+ query: params.toString(),
+ }), { openExternal: true });
+ } else {
+ await openerService.open(URI.from({
+ scheme,
+ authority: Schemas.file,
+ path: folderUri.path,
+ query: params.toString(),
+ }), { openExternal: true });
+ }
+ }
});
registerAction2(DebugAgentHostInDevToolsAction);
diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts
index 052afa16a8373..f09d19059bd9d 100644
--- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts
+++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts
@@ -4,12 +4,14 @@
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
+import { timeout } from '../../../../../base/common/async.js';
import { Codicon } from '../../../../../base/common/codicons.js';
import { Emitter, Event } from '../../../../../base/common/event.js';
import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';
import { ISettableObservable, observableValue } from '../../../../../base/common/observable.js';
import { URI } from '../../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
+import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js';
import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js';
import { RemoteAgentHostConnectionStatus, IRemoteAgentHostService } from '../../../../../platform/agentHost/common/remoteAgentHostService.js';
import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js';
@@ -26,7 +28,6 @@ import { ISessionsProvider } from '../../../../services/sessions/common/sessions
import { IAgentHostSessionsProvider } from '../../../../common/agentHostSessionsProvider.js';
import { ISessionWorkspace } from '../../../../services/sessions/common/session.js';
import { WorkspacePicker, IWorkspaceSelection } from '../../browser/sessionWorkspacePicker.js';
-import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js';
import { IWorkspacesService } from '../../../../../platform/workspaces/common/workspaces.js';
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
import { ICommandService } from '../../../../../platform/commands/common/commands.js';
@@ -77,7 +78,11 @@ function createMockProvider(id: string, opts?: {
getSessionConfigCompletions: async () => [],
getCreateSessionConfig: () => undefined,
clearSessionConfig: () => { },
- } as IAgentHostSessionsProvider;
+ onDidChangeRootConfig: Event.None,
+ getRootConfig: () => undefined,
+ setRootConfigValue: async () => { },
+ replaceRootConfig: async () => { },
+ } as unknown as IAgentHostSessionsProvider;
}
return base;
}
@@ -133,9 +138,6 @@ function createTestPicker(
instantiationService.stub(IStorageService, storage);
instantiationService.stub(IUriIdentityService, { extUri });
instantiationService.stub(ISessionsProvidersService, providersService);
- instantiationService.stub(ISessionsManagementService, {
- activeProviderId: observableValue('activeProviderId', undefined),
- });
instantiationService.stub(IRemoteAgentHostService, {});
instantiationService.stub(IQuickInputService, {});
instantiationService.stub(IClipboardService, {});
@@ -175,7 +177,10 @@ suite('WorkspacePicker - Connection Status', () => {
ensureNoDisposablesAreLeakedInTestSuite();
- test('restore skips unavailable (disconnected) provider', () => {
+ test('restore picks checked entry even when remote is disconnected (before grace period)', () => {
+ // Restore is honored synchronously: the picker shows the checked entry
+ // while we wait to see if the connection comes up. The grace-period
+ // fallback (covered in a separate test) only fires later.
const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Disconnected);
const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });
const localProvider = createMockProvider('local-1');
@@ -189,12 +194,94 @@ suite('WorkspacePicker - Connection Status', () => {
providersService.setProviders([remoteProvider, localProvider]);
const picker = createTestPicker(disposables, providersService, storage);
- // The checked entry is from a disconnected provider — should fall back to local
- assertSelectedProvider(picker, 'local-1');
+ assertSelectedProvider(picker, 'agenthost-remote-1');
});
- test('restore skips connecting provider', () => {
- const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Connecting);
+ test('restored remote that never connects falls back after grace period', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
+ // The provider is registered as Disconnected and never transitions —
+ // e.g. SSH host is unreachable and the status was set before the picker
+ // could subscribe. The picker should fall back to no selection after
+ // the grace period so the view pane drops the stale session.
+ const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Disconnected);
+ const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });
+
+ const storage = disposables.add(new TestStorageService());
+ seedStorage(storage, [
+ { uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true },
+ ]);
+
+ providersService.setProviders([remoteProvider]);
+ const picker = createTestPicker(disposables, providersService, storage);
+
+ assertSelectedProvider(picker, 'agenthost-remote-1', 'Selection is restored synchronously');
+
+ const events: Array = [];
+ disposables.add(picker.onDidSelectWorkspace(e => events.push(e)));
+
+ // Advance past the grace period.
+ await timeout(10_000);
+
+ assertSelectedProvider(picker, undefined, 'Selection cleared after grace period');
+ assert.deepStrictEqual(events, [undefined], 'onDidSelectWorkspace fired with undefined');
+ }));
+
+ test('restored remote that connects within grace period keeps selection', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
+ const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Disconnected);
+ const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });
+
+ const storage = disposables.add(new TestStorageService());
+ seedStorage(storage, [
+ { uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true },
+ ]);
+
+ providersService.setProviders([remoteProvider]);
+ const picker = createTestPicker(disposables, providersService, storage);
+
+ // Connection succeeds quickly.
+ await timeout(100);
+ remoteStatus.set(RemoteAgentHostConnectionStatus.Connecting, undefined);
+ await timeout(500);
+ remoteStatus.set(RemoteAgentHostConnectionStatus.Connected, undefined);
+
+ // Advance past the grace period — should not fall back since we connected.
+ await timeout(10_000);
+
+ assertSelectedProvider(picker, 'agenthost-remote-1', 'Selection preserved after successful connect');
+ }));
+
+ test('user pick during connect cancels the fallback', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
+ // If the user picks a different workspace while the restore-grace-period
+ // timer is running, the timer must not later clear the user's selection.
+ const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Disconnected);
+ const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });
+ const localProvider = createMockProvider('local-1');
+
+ const storage = disposables.add(new TestStorageService());
+ seedStorage(storage, [
+ { uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true },
+ ]);
+
+ providersService.setProviders([remoteProvider, localProvider]);
+ const picker = createTestPicker(disposables, providersService, storage);
+
+ // User picks a local workspace while the remote is still trying to connect.
+ const localPick: IWorkspaceSelection = {
+ providerId: 'local-1',
+ workspace: localProvider.resolveWorkspace(URI.file('/local/picked'))!,
+ };
+ picker.setSelectedWorkspace(localPick, false);
+
+ // Grace period elapses; remote still disconnected — must not affect user pick.
+ await timeout(10_000);
+
+ assertSelectedProvider(picker, 'local-1', 'User pick preserved across grace-period elapse');
+ }));
+
+ test('restore picks checked entry while remote is connecting (no fallback flicker)', () => {
+ // SSH remote: provider registers in Disconnected state and immediately
+ // starts connecting. We restore the checked entry immediately rather than
+ // falling back to a different workspace and swapping later.
+ const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Disconnected);
const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });
const localProvider = createMockProvider('local-1');
@@ -207,11 +294,22 @@ suite('WorkspacePicker - Connection Status', () => {
providersService.setProviders([remoteProvider, localProvider]);
const picker = createTestPicker(disposables, providersService, storage);
- assertSelectedProvider(picker, 'local-1');
+ assertSelectedProvider(picker, 'agenthost-remote-1');
+
+ // Connection attempt starts (no fallback while connecting).
+ remoteStatus.set(RemoteAgentHostConnectionStatus.Connecting, undefined);
+ assertSelectedProvider(picker, 'agenthost-remote-1');
+
+ // After connection completes, selection is unchanged.
+ remoteStatus.set(RemoteAgentHostConnectionStatus.Connected, undefined);
+ assertSelectedProvider(picker, 'agenthost-remote-1');
});
- test('restore picks connected remote provider', () => {
- const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Connected);
+ test('connecting provider that fails falls back to no selection', () => {
+ // Real SSH remote lifecycle: starts Disconnected, transitions Connecting,
+ // then fails back to Disconnected. The picker must clear the selection
+ // and fire onDidSelectWorkspace(undefined) so the view pane calls unsetNewSession().
+ const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Disconnected);
const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });
const storage = disposables.add(new TestStorageService());
@@ -222,10 +320,23 @@ suite('WorkspacePicker - Connection Status', () => {
providersService.setProviders([remoteProvider]);
const picker = createTestPicker(disposables, providersService, storage);
- assertSelectedProvider(picker, 'agenthost-remote-1');
+ assertSelectedProvider(picker, 'agenthost-remote-1', 'Selection is restored while connecting');
+
+ const events: Array = [];
+ disposables.add(picker.onDidSelectWorkspace(e => events.push(e)));
+
+ // SSH tunnel begins.
+ remoteStatus.set(RemoteAgentHostConnectionStatus.Connecting, undefined);
+ assertSelectedProvider(picker, 'agenthost-remote-1', 'Selection preserved while connecting');
+
+ // SSH tunnel fails.
+ remoteStatus.set(RemoteAgentHostConnectionStatus.Disconnected, undefined);
+
+ assertSelectedProvider(picker, undefined, 'Selection cleared after connection failure');
+ assert.deepStrictEqual(events, [undefined], 'onDidSelectWorkspace fired with undefined');
});
- test('disconnect clears selection from that provider', () => {
+ test('restore picks connected remote provider', () => {
const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Connected);
const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });
@@ -236,14 +347,11 @@ suite('WorkspacePicker - Connection Status', () => {
providersService.setProviders([remoteProvider]);
const picker = createTestPicker(disposables, providersService, storage);
- assertSelectedProvider(picker, 'agenthost-remote-1');
- // Disconnect
- remoteStatus.set(RemoteAgentHostConnectionStatus.Disconnected, undefined);
- assertSelectedProvider(picker, undefined, 'Selection should be cleared after disconnect');
+ assertSelectedProvider(picker, 'agenthost-remote-1');
});
- test('reconnect restores the same workspace', () => {
+ test('disconnect preserves selection (renders grayed; no auto-clear)', () => {
const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Connected);
const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });
@@ -256,40 +364,32 @@ suite('WorkspacePicker - Connection Status', () => {
const picker = createTestPicker(disposables, providersService, storage);
assertSelectedProvider(picker, 'agenthost-remote-1');
- // Disconnect — clears selection
+ // Disconnect — selection is preserved (the user picked it; we keep honoring it).
remoteStatus.set(RemoteAgentHostConnectionStatus.Disconnected, undefined);
- assertSelectedProvider(picker, undefined, 'Should clear on disconnect');
-
- // Reconnect — should restore
- remoteStatus.set(RemoteAgentHostConnectionStatus.Connected, undefined);
- assertSelectedProvider(picker, 'agenthost-remote-1', 'Should restore after reconnect');
- assert.strictEqual(
- picker.selectedProject?.workspace.repositories[0]?.uri.path,
- '/remote/project',
- 'Should restore the same workspace URI',
- );
+ assertSelectedProvider(picker, 'agenthost-remote-1', 'Selection should be preserved on disconnect');
});
- test('disconnect does not auto-select another provider workspace', () => {
+ test('reconnect keeps the selection (no extra event fires)', () => {
const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Connected);
const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });
- const localProvider = createMockProvider('local-1');
const storage = disposables.add(new TestStorageService());
seedStorage(storage, [
{ uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true },
- { uri: URI.file('/local/project'), providerId: 'local-1', checked: false },
]);
- providersService.setProviders([remoteProvider, localProvider]);
+ providersService.setProviders([remoteProvider]);
const picker = createTestPicker(disposables, providersService, storage);
assertSelectedProvider(picker, 'agenthost-remote-1');
- // Disconnect remote
+ // Disconnect / reconnect cycle — selection preserved throughout.
remoteStatus.set(RemoteAgentHostConnectionStatus.Disconnected, undefined);
-
- // Should NOT auto-select local workspace — should remain empty
- assertSelectedProvider(picker, undefined, 'Should not auto-select another provider on disconnect');
+ remoteStatus.set(RemoteAgentHostConnectionStatus.Connected, undefined);
+ assertSelectedProvider(picker, 'agenthost-remote-1');
+ assert.strictEqual(
+ picker.selectedProject?.workspace.repositories[0]?.uri.path,
+ '/remote/project',
+ );
});
test('checked is globally unique after persist', () => {
@@ -324,45 +424,82 @@ suite('WorkspacePicker - Connection Status', () => {
assert.strictEqual(checkedEntries[0].providerId, 'local-1', 'The local entry should be checked');
});
- test('onDidSelectWorkspace fires on reconnect restore', () => {
- const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Connected);
- const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });
+ test('local provider is never treated as unavailable', () => {
+ const localProvider = createMockProvider('local-1');
const storage = disposables.add(new TestStorageService());
seedStorage(storage, [
- { uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true },
+ { uri: URI.file('/local/project'), providerId: 'local-1', checked: true },
]);
- providersService.setProviders([remoteProvider]);
+ providersService.setProviders([localProvider]);
const picker = createTestPicker(disposables, providersService, storage);
- const selected: IWorkspaceSelection[] = [];
- disposables.add(picker.onDidSelectWorkspace(w => {
- if (w) {
- selected.push(w);
- }
- }));
+ assertSelectedProvider(picker, 'local-1', 'Local provider workspace should always be selectable');
+ });
- // Disconnect then reconnect
- remoteStatus.set(RemoteAgentHostConnectionStatus.Disconnected, undefined);
- remoteStatus.set(RemoteAgentHostConnectionStatus.Connected, undefined);
+ test('restore picks the stored workspace when its provider registers after another provider', () => {
+ // Regression: previously the picker filtered restore through `activeProviderId`,
+ // which auto-locked to whichever provider registered first. If the stored
+ // workspace belonged to a provider that registered later than another available
+ // provider (for example, local-agent-host registering after default-copilot),
+ // the stored entry was filtered out and never restored.
+ //
+ // Realistic shape: storage holds BOTH a (non-checked) recent for the
+ // early-registering provider and a (checked) recent for the late-registering
+ // provider. The picker may briefly show the early recent as a fallback, but
+ // once the checked entry's provider registers, the picker must upgrade to it.
+ const copilotProvider = createMockProvider('default-copilot');
- assert.strictEqual(selected.length, 1, 'onDidSelectWorkspace should fire once on reconnect');
- assert.strictEqual(selected[0].providerId, 'agenthost-remote-1');
- assert.strictEqual(selected[0].workspace.repositories[0]?.uri.path, '/remote/project', 'Event should carry the correct workspace URI');
+ const storage = disposables.add(new TestStorageService());
+ seedStorage(storage, [
+ { uri: URI.file('/copilot/old-project'), providerId: 'default-copilot', checked: false },
+ { uri: URI.file('/agent-host/project'), providerId: 'local-agent-host', checked: true },
+ ]);
+
+ // Construct picker with only the early-registering provider available.
+ providersService.setProviders([copilotProvider]);
+ const picker = createTestPicker(disposables, providersService, storage);
+
+ // The fallback may be selected initially (early provider's recent),
+ // since the user's checked entry's provider isn't ready yet.
+ // Now the late provider arrives.
+ const agentHostProvider = createMockProvider('local-agent-host');
+ providersService.setProviders([copilotProvider, agentHostProvider]);
+
+ assertSelectedProvider(picker, 'local-agent-host', 'Stored workspace should be restored once its provider registers');
});
- test('local provider is never treated as unavailable', () => {
- const localProvider = createMockProvider('local-1');
+ test('late-registering provider does not move selection out from under user', () => {
+ // After the user has explicitly picked a workspace, a provider
+ // registering later in the session must not switch the selection to its
+ // stored "checked" entry. We only do that auto-upgrade during initial
+ // startup before the user has acted.
+ const copilotProvider = createMockProvider('default-copilot');
const storage = disposables.add(new TestStorageService());
seedStorage(storage, [
- { uri: URI.file('/local/project'), providerId: 'local-1', checked: true },
+ { uri: URI.file('/agent-host/project'), providerId: 'local-agent-host', checked: true },
]);
- providersService.setProviders([localProvider]);
+ providersService.setProviders([copilotProvider]);
const picker = createTestPicker(disposables, providersService, storage);
- assertSelectedProvider(picker, 'local-1', 'Local provider workspace should always be selectable');
+ // Suppression kicked in: no fallback selection while checked entry is pending.
+ assertSelectedProvider(picker, undefined, 'No fallback while checked entry pending');
+
+ // User explicitly picks a Copilot workspace.
+ const copilotPick: IWorkspaceSelection = {
+ providerId: 'default-copilot',
+ workspace: copilotProvider.resolveWorkspace(URI.file('/copilot/picked'))!,
+ };
+ picker.setSelectedWorkspace(copilotPick, false);
+ assertSelectedProvider(picker, 'default-copilot', 'User pick is honored');
+
+ // Now the late provider for the (still-stored) checked entry arrives.
+ const agentHostProvider = createMockProvider('local-agent-host');
+ providersService.setProviders([copilotProvider, agentHostProvider]);
+
+ assertSelectedProvider(picker, 'default-copilot', 'User selection is preserved across late provider registration');
});
});
diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts
index d28853194f27e..af75eb0144c43 100644
--- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts
+++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts
@@ -80,6 +80,9 @@ Registry.as(Extensions.Configuration).registerDefaultCon
'terminal.integrated.initialHint': false,
+ 'workbench.browser.openLocalhostLinks': true,
+ 'workbench.browser.enableChatTools': false,
+
'workbench.editor.doubleClickTabToToggleEditorGroupSizes': 'maximize',
'workbench.editor.restoreEditors': false,
'update.showReleaseNotes': false,
diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilter.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilter.contribution.ts
index 45a8134f90a12..bf233a27c8807 100644
--- a/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilter.contribution.ts
+++ b/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilter.contribution.ts
@@ -12,6 +12,7 @@ import { IsWebContext } from '../../../../platform/contextkey/common/contextkeys
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js';
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js';
+import { IsNewChatSessionContext } from '../../../common/contextkeys.js';
import { Menus } from '../../../browser/menus.js';
import { IAgentHostFilterService } from '../common/agentHostFilter.js';
import { HostFilterActionViewItem } from './hostFilterActionViewItem.js';
@@ -46,6 +47,21 @@ registerAction2(class PickAgentHostFilterAction extends Action2 {
IsAuxiliaryWindowContext.toNegated(),
HasAgentHostsContext,
),
+ }, {
+ // On phone/mobile layouts the desktop titlebar is replaced
+ // by the MobileTitlebarPart. Surface the host picker in its
+ // center slot while a new (empty) chat session is active,
+ // so users can still switch hosts and connect from the
+ // home screen.
+ id: Menus.MobileTitleBarCenter,
+ group: 'navigation',
+ order: 0,
+ when: ContextKeyExpr.and(
+ IsWebContext,
+ IsAuxiliaryWindowContext.toNegated(),
+ HasAgentHostsContext,
+ IsNewChatSessionContext,
+ ),
}],
});
}
@@ -79,6 +95,13 @@ class AgentHostFilterContribution extends Disposable implements IWorkbenchContri
(action, _options, instaService) => instaService.createInstance(HostFilterActionViewItem, action),
filterService.onDidChange,
));
+
+ this._register(actionViewItemService.register(
+ Menus.MobileTitleBarCenter,
+ PICK_HOST_FILTER_ID,
+ (action, _options, instaService) => instaService.createInstance(HostFilterActionViewItem, action),
+ filterService.onDidChange,
+ ));
}
private _update(filterService: IAgentHostFilterService): void {
diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilterActionViewItem.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilterActionViewItem.ts
index a618abe464697..6ae4928685b99 100644
--- a/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilterActionViewItem.ts
+++ b/src/vs/sessions/contrib/remoteAgentHost/browser/hostFilterActionViewItem.ts
@@ -5,6 +5,7 @@
import './media/hostFilter.css';
import * as dom from '../../../../base/browser/dom.js';
+import { Gesture, EventType as TouchEventType } from '../../../../base/browser/touch.js';
import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
import { BaseActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js';
import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js';
@@ -74,22 +75,23 @@ export class HostFilterActionViewItem extends BaseActionViewItem {
this._chevronElement = dom.append(this._dropdownElement, dom.$('span.agent-host-filter-chevron'));
this._chevronElement.append(...renderLabelWithIcons(`$(${Codicon.chevronDown.id})`));
- this._register(dom.addDisposableListener(this._dropdownElement, dom.EventType.CLICK, e => {
- if (!this._isInteractive()) {
- return;
- }
- e.preventDefault();
- e.stopPropagation();
- this._showMenu(e);
- }));
+ this._register(Gesture.addTarget(this._dropdownElement));
+ for (const eventType of [dom.EventType.CLICK, TouchEventType.Tap]) {
+ this._register(dom.addDisposableListener(this._dropdownElement, eventType, e => {
+ if (!this._isInteractive()) {
+ return;
+ }
+ dom.EventHelper.stop(e, true);
+ this._showMenu(e);
+ }));
+ }
this._register(dom.addDisposableListener(this._dropdownElement, dom.EventType.KEY_DOWN, e => {
if (!this._isInteractive()) {
return;
}
const event = new StandardKeyboardEvent(e);
if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) {
- e.preventDefault();
- e.stopPropagation();
+ dom.EventHelper.stop(e, true);
this._showMenu(e);
}
}));
@@ -97,16 +99,17 @@ export class HostFilterActionViewItem extends BaseActionViewItem {
// --- Connection button (right) ------------------------------------------
this._connectElement = dom.append(this.element, dom.$('div.agent-host-filter-connect'));
- this._register(dom.addDisposableListener(this._connectElement, dom.EventType.CLICK, e => {
- e.preventDefault();
- e.stopPropagation();
- this._onConnectClick();
- }));
+ this._register(Gesture.addTarget(this._connectElement));
+ for (const eventType of [dom.EventType.CLICK, TouchEventType.Tap]) {
+ this._register(dom.addDisposableListener(this._connectElement, eventType, e => {
+ dom.EventHelper.stop(e, true);
+ this._onConnectClick();
+ }));
+ }
this._register(dom.addDisposableListener(this._connectElement, dom.EventType.KEY_DOWN, e => {
const event = new StandardKeyboardEvent(e);
if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) {
- e.preventDefault();
- e.stopPropagation();
+ dom.EventHelper.stop(e, true);
this._onConnectClick();
}
}));
diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/media/hostFilter.css b/src/vs/sessions/contrib/remoteAgentHost/browser/media/hostFilter.css
index fb19b063af93f..8286dc80341f7 100644
--- a/src/vs/sessions/contrib/remoteAgentHost/browser/media/hostFilter.css
+++ b/src/vs/sessions/contrib/remoteAgentHost/browser/media/hostFilter.css
@@ -5,8 +5,12 @@
/* Compound widget (dropdown pill + connect button). Expands to fill the
* space available in the titlebar's left toolbar after the sidebar toggle,
- * so the pill + connect button appear centered in the remaining width. */
-.agent-host-filter-combo {
+ * so the pill + connect button appear centered in the remaining width.
+ *
+ * Higher specificity than the default `.monaco-action-bar .action-item`
+ * (which sets `display: block`) so the li lays out its two child controls
+ * (dropdown + connect) as a horizontal flex row. */
+.monaco-action-bar .action-item.agent-host-filter-combo {
display: flex;
align-items: center;
justify-content: center;
diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts
index df0ddee4e0ae2..959c2e4b72a88 100644
--- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts
+++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts
@@ -10,6 +10,7 @@ import { DisposableStore } from '../../../../base/common/lifecycle.js';
import { Schemas } from '../../../../base/common/network.js';
import { basename, dirname } from '../../../../base/common/resources.js';
import { IObservable, observableValue } from '../../../../base/common/observable.js';
+import { isWeb } from '../../../../base/common/platform.js';
import { ThemeIcon } from '../../../../base/common/themables.js';
import { URI } from '../../../../base/common/uri.js';
import { localize } from '../../../../nls.js';
@@ -145,6 +146,14 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid
private readonly _onDidDisconnect = this._register(new Emitter());
protected override get onConnectionLost(): Event { return this._onDidDisconnect.event; }
+ /**
+ * Overridable seam so tests can exercise both the web and non-web
+ * branches of the label/description gating without depending on the
+ * ambient {@link isWeb} constant (the browser test runner always
+ * reports `isWeb === true`).
+ */
+ protected get isWebPlatform(): boolean { return isWeb; }
+
private _connection: IAgentConnection | undefined;
private _defaultDirectory: string | undefined;
private readonly _connectionListeners = this._register(new DisposableStore());
@@ -246,12 +255,13 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid
}
protected _adapterOptions() {
+ const web = this.isWebPlatform;
return {
- description: new MarkdownString().appendText(this.label),
+ description: web ? undefined : new MarkdownString().appendText(this.label),
buildWorkspace: (project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined) => {
const uriForDescription = project?.uri ?? workingDirectory;
const description = uriForDescription ? this._labelService.getUriLabel(dirname(uriForDescription), { relative: false }) : undefined;
- return buildAgentHostSessionWorkspace(project, workingDirectory, { providerLabel: this.label, fallbackIcon: Codicon.remote, requiresWorkspaceTrust: false, description });
+ return buildAgentHostSessionWorkspace(project, workingDirectory, { providerLabel: web ? undefined : this.label, fallbackIcon: Codicon.remote, requiresWorkspaceTrust: false, description });
},
};
}
@@ -361,9 +371,11 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid
const rootStateValue = connection.rootState.value;
if (rootStateValue && !(rootStateValue instanceof Error)) {
this._syncSessionTypesFromRootState(rootStateValue);
+ this._syncRootConfigFromRootState(rootStateValue);
}
this._connectionListeners.add(connection.rootState.onDidChange(rootState => {
this._syncSessionTypesFromRootState(rootState);
+ this._syncRootConfigFromRootState(rootState);
}));
this._attachConnectionListeners(connection, this._connectionListeners);
@@ -496,7 +508,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid
private _buildWorkspaceFromUri(uri: URI): ISessionWorkspace {
const folderName = basename(uri) || uri.path;
return {
- label: `${folderName} [${this.label}]`,
+ label: this.isWebPlatform ? folderName : `${folderName} [${this.label}]`,
description: this._labelService.getUriLabel(dirname(uri), { relative: false }),
group: this.label,
icon: Codicon.remote,
diff --git a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts
index 7799f9eabbe48..83487264714ec 100644
--- a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts
+++ b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts
@@ -12,7 +12,7 @@ import { mock } from '../../../../../base/test/common/mock.js';
import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { AgentSession, type IAgentConnection, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js';
-import type { SessionAction, TerminalAction } from '../../../../../platform/agentHost/common/state/protocol/action-origin.generated.js';
+import type { RootAction, SessionAction, TerminalAction } from '../../../../../platform/agentHost/common/state/protocol/action-origin.generated.js';
import type { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js';
import { NotificationType } from '../../../../../platform/agentHost/common/state/protocol/notifications.js';
import { SessionLifecycle, type AgentInfo, type ModelSelection, type RootState, type SessionConfigState, type SessionState } from '../../../../../platform/agentHost/common/state/protocol/state.js';
@@ -49,7 +49,7 @@ class MockAgentConnection extends mock() {
override readonly clientId = 'test-client-1';
private readonly _sessions = new Map();
public disposedSessions: URI[] = [];
- public dispatchedActions: { action: SessionAction | TerminalAction; clientId: string; clientSeq: number }[] = [];
+ public dispatchedActions: { action: RootAction | SessionAction | TerminalAction; clientId: string; clientSeq: number }[] = [];
public failResolveSessionConfig = false;
public resolveSessionConfigResult: ResolveSessionConfigResult = { schema: { type: 'object', properties: {} }, values: { isolation: 'worktree' } };
@@ -89,11 +89,11 @@ class MockAgentConnection extends mock() {
return this.resolveSessionConfigResult;
}
- dispatchAction(action: SessionAction | TerminalAction, clientId: string, clientSeq: number): void {
+ dispatchAction(action: RootAction | SessionAction | TerminalAction, clientId: string, clientSeq: number): void {
this.dispatchedActions.push({ action, clientId, clientSeq });
}
- override dispatch(action: SessionAction | TerminalAction): void {
+ override dispatch(action: RootAction | SessionAction | TerminalAction): void {
this.dispatchedActions.push({ action, clientId: this.clientId, clientSeq: this._nextSeq++ });
}
@@ -177,7 +177,7 @@ function createSession(id: string, opts?: { provider?: string; summary?: string;
};
}
-function createProvider(disposables: DisposableStore, connection: MockAgentConnection, overrides?: { address?: string; connectionName?: string | undefined; sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; openSession?: boolean; storageService?: IStorageService; noConnection?: boolean }): RemoteAgentHostSessionsProvider {
+function createProvider(disposables: DisposableStore, connection: MockAgentConnection, overrides?: { address?: string; connectionName?: string | undefined; sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; openSession?: boolean; storageService?: IStorageService; noConnection?: boolean; isWebPlatform?: boolean }): RemoteAgentHostSessionsProvider {
const instantiationService = disposables.add(new TestInstantiationService());
instantiationService.stub(IFileDialogService, {});
@@ -206,7 +206,12 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne
name: overrides !== undefined && Object.prototype.hasOwnProperty.call(overrides, 'connectionName') ? overrides.connectionName ?? '' : 'Test Host',
};
- const provider = disposables.add(instantiationService.createInstance(RemoteAgentHostSessionsProvider, config));
+ const providerCtor = overrides?.isWebPlatform !== undefined
+ ? class extends RemoteAgentHostSessionsProvider {
+ protected override get isWebPlatform(): boolean { return overrides.isWebPlatform!; }
+ }
+ : RemoteAgentHostSessionsProvider;
+ const provider = disposables.add(instantiationService.createInstance(providerCtor, config));
if (!overrides?.noConnection) {
provider.setConnection(connection);
}
@@ -312,12 +317,12 @@ suite('RemoteAgentHostSessionsProvider', () => {
// ---- Workspace resolution -------
test('resolveWorkspace builds workspace from URI', () => {
- const provider = createProvider(disposables, connection);
+ const provider = createProvider(disposables, connection, { isWebPlatform: true });
const uri = URI.parse('vscode-agent-host://auth/home/user/project');
const ws = provider.resolveWorkspace(uri);
assert.ok(ws, 'resolveWorkspace should resolve vscode-agent-host:// URIs');
- assert.strictEqual(ws.label, 'project [Test Host]');
+ assert.strictEqual(ws.label, 'project');
assert.strictEqual(ws.repositories.length, 1);
assert.strictEqual(ws.repositories[0].uri.toString(), uri.toString());
assert.strictEqual(ws.repositories[0].detail, undefined);
@@ -400,7 +405,7 @@ suite('RemoteAgentHostSessionsProvider', () => {
workingDirectory,
}));
- const provider = createProvider(disposables, connection);
+ const provider = createProvider(disposables, connection, { isWebPlatform: true });
provider.getSessions();
await timeout(0);
@@ -411,7 +416,7 @@ suite('RemoteAgentHostSessionsProvider', () => {
workingDirectory: workspace?.repositories[0]?.workingDirectory?.toString(),
detail: workspace?.repositories[0]?.detail,
}, {
- label: 'vscode [Test Host]',
+ label: 'vscode',
repository: projectUri.toString(),
workingDirectory: workingDirectory.toString(),
detail: undefined,
@@ -521,13 +526,13 @@ suite('RemoteAgentHostSessionsProvider', () => {
// ---- Session lifecycle -------
test('createNewSession returns session with correct fields', () => {
- const provider = createProvider(disposables, connection);
+ const provider = createProvider(disposables, connection, { isWebPlatform: true });
const session = provider.createNewSession(URI.parse('vscode-agent-host://auth/home/user/project'), provider.sessionTypes[0].id);
assert.strictEqual(session.providerId, provider.id);
assert.strictEqual(session.status.get(), SessionStatus.Untitled);
assert.ok(session.workspace.get());
- assert.strictEqual(session.workspace.get()?.label, 'project [Test Host]');
+ assert.strictEqual(session.workspace.get()?.label, 'project');
// sessionType should be the logical type, not the resource scheme
assert.strictEqual(session.sessionType, provider.sessionTypes[0].id);
assert.deepStrictEqual(provider.getSessionConfig(session.sessionId), { schema: { type: 'object', properties: {} }, values: {} });
@@ -535,7 +540,7 @@ suite('RemoteAgentHostSessionsProvider', () => {
test('createNewSession clears session config when resolving config is unavailable', async () => {
connection.failResolveSessionConfig = true;
- const provider = createProvider(disposables, connection);
+ const provider = createProvider(disposables, connection, { isWebPlatform: true });
const workspaceUri = URI.parse('vscode-agent-host://auth/home/user/project');
const session = provider.createNewSession(workspaceUri, provider.sessionTypes[0].id);
const resolved = provider.getSessionByResource(session.resource);
@@ -547,7 +552,7 @@ suite('RemoteAgentHostSessionsProvider', () => {
}, {
listedSessions: 0,
resolvedResource: session.resource.toString(),
- resolvedWorkspaceLabel: 'project [Test Host]',
+ resolvedWorkspaceLabel: 'project',
});
});
@@ -855,7 +860,7 @@ suite('RemoteAgentHostSessionsProvider', () => {
test('session adapter has correct workspace from working directory', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
connection.addSession(createSession('ws-sess', { summary: 'WS Test', workingDirectory: URI.parse('vscode-agent-host://localhost__4321/file/-/home/user/myrepo') }));
- const provider = createProvider(disposables, connection);
+ const provider = createProvider(disposables, connection, { isWebPlatform: true });
provider.getSessions();
await timeout(0);
@@ -865,7 +870,7 @@ suite('RemoteAgentHostSessionsProvider', () => {
const workspace = wsSession!.workspace.get();
assert.ok(workspace, 'Workspace should be populated');
- assert.strictEqual(workspace!.label, 'myrepo [Test Host]');
+ assert.strictEqual(workspace!.label, 'myrepo');
assert.strictEqual(workspace!.repositories[0].detail, undefined);
}));
@@ -1008,4 +1013,82 @@ suite('RemoteAgentHostSessionsProvider', () => {
assert.strictEqual(connection.sessionUnsubscribeCounts.get(sessionUriStr), 1);
}));
+ // ---- Non-web label formatting (native desktop) -------
+ //
+ // In the browser test runner `isWeb` is always `true`, so by default
+ // every test above exercises the web branch (which drops the
+ // `[]` suffix because the titlebar host filter renders it
+ // redundantly). These tests pin the non-web (desktop) behaviour where
+ // the host suffix / host description must still appear.
+
+ test('non-web: resolveWorkspace includes [host] suffix in label', () => {
+ const provider = createProvider(disposables, connection, { isWebPlatform: false });
+ const uri = URI.parse('vscode-agent-host://auth/home/user/project');
+ const ws = provider.resolveWorkspace(uri);
+
+ assert.ok(ws);
+ assert.strictEqual(ws.label, 'project [Test Host]');
+ });
+
+ test('non-web: session workspace from project metadata includes [host] suffix', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
+ const projectUri = URI.parse('vscode-agent-host://localhost__4321/file/-/home/user/vscode');
+ connection.addSession(createSession('project-1', {
+ summary: 'Project Session',
+ project: { uri: projectUri, displayName: 'vscode' },
+ }));
+
+ const provider = createProvider(disposables, connection, { isWebPlatform: false });
+ provider.getSessions();
+ await timeout(0);
+
+ assert.strictEqual(provider.getSessions()[0].workspace.get()?.label, 'vscode [Test Host]');
+ }));
+
+ test('non-web: session workspace from working directory includes [host] suffix', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
+ connection.addSession(createSession('ws-sess', {
+ summary: 'WS Test',
+ workingDirectory: URI.parse('vscode-agent-host://localhost__4321/file/-/home/user/myrepo'),
+ }));
+
+ const provider = createProvider(disposables, connection, { isWebPlatform: false });
+ provider.getSessions();
+ await timeout(0);
+
+ const wsSession = provider.getSessions().find(s => s.title.get() === 'WS Test');
+ assert.strictEqual(wsSession?.workspace.get()?.label, 'myrepo [Test Host]');
+ }));
+
+ test('non-web: createNewSession workspace label includes [host] suffix', () => {
+ const provider = createProvider(disposables, connection, { isWebPlatform: false });
+ const session = provider.createNewSession(URI.parse('vscode-agent-host://auth/home/user/project'), provider.sessionTypes[0].id);
+
+ assert.strictEqual(session.workspace.get()?.label, 'project [Test Host]');
+ });
+
+ test('non-web: session description is the host label', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
+ connection.addSession(createSession('desc-sess', { summary: 'Desc Test' }));
+
+ const provider = createProvider(disposables, connection, { isWebPlatform: false });
+ provider.getSessions();
+ await timeout(0);
+
+ const session = provider.getSessions().find(s => s.title.get() === 'Desc Test');
+ const description = session?.description.get();
+ assert.ok(description, 'description should be defined on non-web');
+ // MarkdownString.appendText escapes spaces as — verify the
+ // host label is present rather than the exact serialized form.
+ assert.ok(description!.value.includes('Test') && description!.value.includes('Host'));
+ }));
+
+ test('web: session description is undefined (host filter dropdown replaces it)', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
+ connection.addSession(createSession('desc-sess-web', { summary: 'Desc Web' }));
+
+ const provider = createProvider(disposables, connection, { isWebPlatform: true });
+ provider.getSessions();
+ await timeout(0);
+
+ const session = provider.getSessions().find(s => s.title.get() === 'Desc Web');
+ assert.strictEqual(session?.description.get(), undefined);
+ }));
+
});
diff --git a/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts b/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts
index cd2bbee6e27b3..cf696435464c1 100644
--- a/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts
+++ b/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts
@@ -21,8 +21,10 @@ import { IPromptsService } from '../../../../workbench/contrib/chat/common/promp
import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js';
import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js';
import { Menus } from '../../../browser/menus.js';
-import { getCustomizationTotalCount } from './customizationCounts.js';
+import { getCustomizationTotalCount, getActiveItemProvider } from './customizationCounts.js';
import { IAgentPluginService } from '../../../../workbench/contrib/chat/common/plugins/agentPluginService.js';
+import { ICustomizationHarnessService } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js';
+import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
const $ = DOM.$;
@@ -46,6 +48,8 @@ export class AICustomizationShortcutsWidget extends Disposable {
@IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService,
@IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService,
@IAgentPluginService private readonly agentPluginService: IAgentPluginService,
+ @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService,
+ @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService,
) {
super();
@@ -101,9 +105,10 @@ export class AICustomizationShortcutsWidget extends Disposable {
}));
let updateCountRequestId = 0;
+
const updateHeaderTotalCount = async () => {
const requestId = ++updateCountRequestId;
- const totalCount = await getCustomizationTotalCount(this.promptsService, this.mcpService, this.workspaceService, this.workspaceContextService, this.agentPluginService);
+ const totalCount = await getCustomizationTotalCount(this.promptsService, this.mcpService, this.workspaceService, this.workspaceContextService, this.agentPluginService, getActiveItemProvider(this.sessionsManagementService, this.harnessService));
if (requestId !== updateCountRequestId) {
return;
}
@@ -123,6 +128,15 @@ export class AICustomizationShortcutsWidget extends Disposable {
this.workspaceService.activeProjectRoot.read(reader);
updateHeaderTotalCount();
}));
+ this._register(autorun(reader => {
+ this.sessionsManagementService.activeSession.read(reader);
+ this.harnessService.availableHarnesses.read(reader);
+ const provider = getActiveItemProvider(this.sessionsManagementService, this.harnessService);
+ if (provider) {
+ reader.store.add(provider.onDidChange(() => updateHeaderTotalCount()));
+ }
+ updateHeaderTotalCount();
+ }));
updateHeaderTotalCount();
// Toggle collapse on header click
diff --git a/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts b/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts
index 9c30d64c313b4..90c1b0719c136 100644
--- a/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts
+++ b/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts
@@ -15,7 +15,9 @@ import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.j
import { IAICustomizationWorkspaceService, applyStorageSourceFilter, IStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js';
import { parseHooksFromFile } from '../../../../workbench/contrib/chat/common/promptSyntax/hookCompatibility.js';
import { IAgentPluginService } from '../../../../workbench/contrib/chat/common/plugins/agentPluginService.js';
+import { ICustomizationHarnessService, ICustomizationItemProvider } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js';
import { parse as parseJSONC } from '../../../../base/common/jsonc.js';
+import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
export interface ISourceCounts {
readonly workspace: number;
@@ -136,19 +138,41 @@ export async function getSourceCounts(
};
}
+const PROMPT_TYPES: PromptsType[] = [PromptsType.agent, PromptsType.skill, PromptsType.instructions, PromptsType.hook];
+const PROMPT_TYPE_SET = new Set(PROMPT_TYPES);
+
export async function getCustomizationTotalCount(
promptsService: IPromptsService,
mcpService: IMcpService,
workspaceService: IAICustomizationWorkspaceService,
workspaceContextService: IWorkspaceContextService,
agentPluginService?: IAgentPluginService,
+ itemProvider?: ICustomizationItemProvider,
): Promise {
- const types: PromptsType[] = [PromptsType.agent, PromptsType.skill, PromptsType.instructions, PromptsType.hook];
- const results = await Promise.all(types.map(type => {
- const filter = workspaceService.getStorageSourceFilter(type);
- return getSourceCounts(promptsService, type, filter, workspaceContextService, workspaceService)
- .then(counts => getSourceCountsTotal(counts, filter));
- }));
+ let promptTotal: number;
+ if (itemProvider) {
+ const allItems = await itemProvider.provideChatSessionCustomizations(CancellationToken.None);
+ promptTotal = allItems?.filter(item => PROMPT_TYPE_SET.has(item.type)).length ?? 0;
+ } else {
+ const results = await Promise.all(PROMPT_TYPES.map(type => {
+ const filter = workspaceService.getStorageSourceFilter(type);
+ return getSourceCounts(promptsService, type, filter, workspaceContextService, workspaceService)
+ .then(counts => getSourceCountsTotal(counts, filter));
+ }));
+ promptTotal = results.reduce((sum, n) => sum + n, 0);
+ }
+
const pluginCount = agentPluginService?.plugins.get().length ?? 0;
- return results.reduce((sum, n) => sum + n, 0) + mcpService.servers.get().length + pluginCount;
+ return promptTotal + mcpService.servers.get().length + pluginCount;
+}
+
+export function getActiveItemProvider(
+ sessionsManagementService: ISessionsManagementService,
+ harnessService: ICustomizationHarnessService,
+): ICustomizationItemProvider | undefined {
+ const sessionType = sessionsManagementService.activeSession.get()?.sessionType;
+ if (sessionType) {
+ return harnessService.findHarnessById(sessionType)?.itemProvider;
+ }
+ return undefined;
}
diff --git a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts
index e0d453627eab0..c4062dc948f2c 100644
--- a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts
+++ b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts
@@ -5,6 +5,7 @@
import '../../../browser/media/sidebarActionButton.css';
import './media/customizationsToolbar.css';
+import { CancellationToken } from '../../../../base/common/cancellation.js';
import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';
import { ThemeIcon } from '../../../../base/common/themables.js';
import { localize, localize2 } from '../../../../nls.js';
@@ -12,6 +13,7 @@ import { Action2, registerAction2 } from '../../../../platform/actions/common/ac
import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js';
import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js';
+import { AICustomizationManagementEditor } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js';
import { AICustomizationManagementEditorInput } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js';
import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js';
import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js';
@@ -27,16 +29,18 @@ import { IWorkspaceContextService } from '../../../../platform/workspace/common/
import { IFileService } from '../../../../platform/files/common/files.js';
import { Button } from '../../../../base/browser/ui/button/button.js';
import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js';
-import { getSourceCounts, getSourceCountsTotal } from './customizationCounts.js';
+import { getSourceCounts, getSourceCountsTotal, getActiveItemProvider } from './customizationCounts.js';
import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js';
-import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js';
+import { AICustomizationManagementSection, IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js';
import { IAgentPluginService } from '../../../../workbench/contrib/chat/common/plugins/agentPluginService.js';
+import { ICustomizationHarnessService } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js';
import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
export interface ICustomizationItemConfig {
readonly id: string;
readonly label: string;
readonly icon: ThemeIcon;
+ readonly section: typeof AICustomizationManagementSection[keyof typeof AICustomizationManagementSection];
readonly promptType?: PromptsType;
readonly isMcp?: boolean;
readonly isPlugins?: boolean;
@@ -47,36 +51,42 @@ export const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [
id: 'sessions.customization.agents',
label: localize('agents', "Agents"),
icon: agentIcon,
+ section: AICustomizationManagementSection.Agents,
promptType: PromptsType.agent,
},
{
id: 'sessions.customization.skills',
label: localize('skills', "Skills"),
icon: skillIcon,
+ section: AICustomizationManagementSection.Skills,
promptType: PromptsType.skill,
},
{
id: 'sessions.customization.instructions',
label: localize('instructions', "Instructions"),
icon: instructionsIcon,
+ section: AICustomizationManagementSection.Instructions,
promptType: PromptsType.instructions,
},
{
id: 'sessions.customization.hooks',
label: localize('hooks', "Hooks"),
icon: hookIcon,
+ section: AICustomizationManagementSection.Hooks,
promptType: PromptsType.hook,
},
{
id: 'sessions.customization.mcpServers',
label: localize('mcpServers', "MCP Servers"),
icon: mcpServerIcon,
+ section: AICustomizationManagementSection.McpServers,
isMcp: true,
},
{
id: 'sessions.customization.plugins',
label: localize('plugins', "Plugins"),
icon: pluginIcon,
+ section: AICustomizationManagementSection.Plugins,
isPlugins: true,
},
];
@@ -103,6 +113,7 @@ export class CustomizationLinkViewItem extends ActionViewItem {
@IAICustomizationWorkspaceService private readonly _workspaceService: IAICustomizationWorkspaceService,
@IFileService private readonly _fileService: IFileService,
@IAgentPluginService private readonly _agentPluginService: IAgentPluginService,
+ @ICustomizationHarnessService private readonly _harnessService: ICustomizationHarnessService,
) {
super(undefined, action, { ...options, icon: false, label: false });
this._viewItemDisposables = this._register(new DisposableStore());
@@ -153,6 +164,11 @@ export class CustomizationLinkViewItem extends ActionViewItem {
this._viewItemDisposables.add(this._workspaceContextService.onDidChangeWorkspaceFolders(() => this._updateCounts()));
this._viewItemDisposables.add(autorun(reader => {
this._activeSessionService.activeSession.read(reader);
+ this._harnessService.availableHarnesses.read(reader);
+ const provider = getActiveItemProvider(this._activeSessionService, this._harnessService);
+ if (provider) {
+ reader.store.add(provider.onDidChange(() => this._updateCounts()));
+ }
this._updateCounts();
}));
@@ -168,16 +184,26 @@ export class CustomizationLinkViewItem extends ActionViewItem {
}
const requestId = ++this._updateCountsRequestId;
+ const itemProvider = getActiveItemProvider(this._activeSessionService, this._harnessService);
if (this._config.promptType) {
- const type = this._config.promptType;
- const filter = this._workspaceService.getStorageSourceFilter(type);
- const counts = await getSourceCounts(this._promptsService, type, filter, this._workspaceContextService, this._workspaceService, this._fileService);
- if (requestId !== this._updateCountsRequestId) {
- return;
+ if (itemProvider) {
+ const allItems = await itemProvider.provideChatSessionCustomizations(CancellationToken.None);
+ if (requestId !== this._updateCountsRequestId) {
+ return;
+ }
+ const total = allItems?.filter(item => item.type === this._config.promptType).length ?? 0;
+ this._renderTotalCount(this._countContainer, total);
+ } else {
+ const type = this._config.promptType;
+ const filter = this._workspaceService.getStorageSourceFilter(type);
+ const counts = await getSourceCounts(this._promptsService, type, filter, this._workspaceContextService, this._workspaceService, this._fileService);
+ if (requestId !== this._updateCountsRequestId) {
+ return;
+ }
+ const total = getSourceCountsTotal(counts, filter);
+ this._renderTotalCount(this._countContainer, total);
}
- const total = getSourceCountsTotal(counts, filter);
- this._renderTotalCount(this._countContainer, total);
} else if (this._config.isMcp) {
const total = this._mcpService.servers.get().length;
this._renderTotalCount(this._countContainer, total);
@@ -231,8 +257,17 @@ export class CustomizationsToolbarContribution extends Disposable implements IWo
}
async run(accessor: ServicesAccessor): Promise {
const editorService = accessor.get(IEditorService);
+ const harnessService = accessor.get(ICustomizationHarnessService);
+ const sessionsManagementService = accessor.get(ISessionsManagementService);
+ const activeSessionType = sessionsManagementService.activeSession.get()?.sessionType;
+ if (activeSessionType && harnessService.findHarnessById(activeSessionType)) {
+ harnessService.setActiveHarness(activeSessionType);
+ }
const input = AICustomizationManagementEditorInput.getOrCreate();
- await editorService.openEditor(input, { pinned: true });
+ const pane = await editorService.openEditor(input, { pinned: true });
+ if (pane instanceof AICustomizationManagementEditor) {
+ pane.selectSectionById(config.section);
+ }
}
}));
}
diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts
index beb6c441354eb..a174012761e2f 100644
--- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts
+++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts
@@ -70,7 +70,7 @@ export class SessionsTitleBarWidget extends BaseActionViewItem {
) {
super(undefined, action, options);
- // Re-render when the active session, its data, or the active provider changes
+ // Re-render when the active session or its data changes
this._register(autorun(reader => {
const sessionData = this.sessionsManagementService.activeSession.read(reader);
if (sessionData) {
@@ -78,7 +78,6 @@ export class SessionsTitleBarWidget extends BaseActionViewItem {
sessionData.status.read(reader);
sessionData.workspace.read(reader);
}
- this.sessionsManagementService.activeProviderId.read(reader);
this._lastRenderState = undefined;
this._render();
}));
diff --git a/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts
index 1b67d7808761e..7c06c93808544 100644
--- a/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts
+++ b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts
@@ -21,6 +21,7 @@ import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/co
import { IMcpServer, IMcpService } from '../../../../../workbench/contrib/mcp/common/mcpTypes.js';
import { IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js';
import { IAgentPluginService } from '../../../../../workbench/contrib/chat/common/plugins/agentPluginService.js';
+import { ICustomizationHarnessService } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js';
import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js';
import { AICustomizationShortcutsWidget } from '../../browser/aiCustomizationShortcutsWidget.js';
import { CUSTOMIZATION_ITEMS, CustomizationLinkViewItem } from '../../browser/customizationsToolbar.contribution.js';
@@ -204,6 +205,10 @@ function renderWidget(ctx: ComponentFixtureContext, options?: { mcpServerCount?:
reg.defineInstance(ISessionsManagementService, new class extends mock() {
override readonly activeSession = observableValue('activeSession', undefined);
}());
+ reg.defineInstance(ICustomizationHarnessService, new class extends mock() {
+ override readonly availableHarnesses = observableValue('availableHarnesses', []);
+ override findHarnessById() { return undefined; }
+ }());
reg.defineInstance(IFileService, new class extends mock() {
override readonly onDidFilesChange = Event.None;
}());
diff --git a/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts b/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts
index 2e4afacb53c13..3bb2f7738ec92 100644
--- a/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts
+++ b/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts
@@ -10,10 +10,14 @@ import { PromptsType } from '../../../../../workbench/contrib/chat/common/prompt
import { IPromptsService, PromptsStorage, IPromptPath, ILocalPromptPath, IUserPromptPath, IExtensionPromptPath, IAgentInstructionFile, AgentInstructionFileType } from '../../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js';
import { IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js';
import { IWorkspaceContextService, IWorkspace, IWorkspaceFolder, WorkbenchState } from '../../../../../platform/workspace/common/workspace.js';
-import { getSourceCounts, getSourceCountsTotal, getCustomizationTotalCount } from '../../browser/customizationCounts.js';
+import { getSourceCounts, getSourceCountsTotal, getCustomizationTotalCount, getActiveItemProvider } from '../../browser/customizationCounts.js';
import { IMcpService } from '../../../../../workbench/contrib/mcp/common/mcpTypes.js';
import { Event } from '../../../../../base/common/event.js';
import { observableValue } from '../../../../../base/common/observable.js';
+import { ICustomizationHarnessService, ICustomizationItem, ICustomizationItemProvider, IHarnessDescriptor } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js';
+import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js';
+import { CancellationToken } from '../../../../../base/common/cancellation.js';
+import { IAgentPluginService } from '../../../../../workbench/contrib/chat/common/plugins/agentPluginService.js';
function localFile(path: string): ILocalPromptPath {
return { uri: URI.file(path), storage: PromptsStorage.local, type: PromptsType.instructions };
@@ -691,6 +695,148 @@ suite('customizationCounts', () => {
});
});
+ suite('getActiveItemProvider', () => {
+ function createMockSessionsService(sessionType?: string): ISessionsManagementService {
+ const activeSession = observableValue(
+ 'test',
+ sessionType ? { sessionType } as IActiveSession : undefined,
+ );
+ return { activeSession } as unknown as ISessionsManagementService;
+ }
+
+ function createMockHarnessService(harnesses: { id: string; itemProvider?: ICustomizationItemProvider }[]): ICustomizationHarnessService {
+ return {
+ findHarnessById: (sessionType: string) => {
+ const h = harnesses.find(h => h.id === sessionType);
+ return h ? { id: h.id, itemProvider: h.itemProvider } as IHarnessDescriptor : undefined;
+ },
+ } as unknown as ICustomizationHarnessService;
+ }
+
+ test('returns undefined when no active session', () => {
+ const sessionsService = createMockSessionsService(undefined);
+ const harnessService = createMockHarnessService([]);
+ assert.strictEqual(getActiveItemProvider(sessionsService, harnessService), undefined);
+ });
+
+ test('returns undefined when session type has no matching harness', () => {
+ const sessionsService = createMockSessionsService('unknown-type');
+ const harnessService = createMockHarnessService([{ id: 'copilotcli' }]);
+ assert.strictEqual(getActiveItemProvider(sessionsService, harnessService), undefined);
+ });
+
+ test('returns undefined when harness has no itemProvider', () => {
+ const sessionsService = createMockSessionsService('copilotcli');
+ const harnessService = createMockHarnessService([{ id: 'copilotcli', itemProvider: undefined }]);
+ assert.strictEqual(getActiveItemProvider(sessionsService, harnessService), undefined);
+ });
+
+ test('returns the itemProvider when harness exists with one', () => {
+ const mockProvider: ICustomizationItemProvider = {
+ onDidChange: Event.None,
+ provideChatSessionCustomizations: async () => [],
+ };
+ const sessionsService = createMockSessionsService('claude-code');
+ const harnessService = createMockHarnessService([{ id: 'claude-code', itemProvider: mockProvider }]);
+ assert.strictEqual(getActiveItemProvider(sessionsService, harnessService), mockProvider);
+ });
+ });
+
+ suite('getCustomizationTotalCount with itemProvider', () => {
+ function createItemProvider(items: ICustomizationItem[]): ICustomizationItemProvider {
+ return {
+ onDidChange: Event.None,
+ provideChatSessionCustomizations: async (_token: CancellationToken) => items,
+ };
+ }
+
+ function makeItem(type: string, name: string): ICustomizationItem {
+ return { uri: URI.file(`/mock/${name}`), type, name, extensionId: undefined, pluginUri: undefined };
+ }
+
+ test('uses itemProvider counts when provided', async () => {
+ const promptsService = createMockPromptsService({});
+ const mcpService = {
+ servers: observableValue('test', [{ id: 's1' }]),
+ } as unknown as IMcpService;
+ const workspaceService = createMockWorkspaceService({ filter: { sources: [PromptsStorage.local] } });
+ const contextService = createMockWorkspaceContextService([]);
+
+ const provider = createItemProvider([
+ makeItem('agent', 'my-agent'),
+ makeItem('skill', 'my-skill'),
+ makeItem('instructions', 'my-instruction'),
+ makeItem('hook', 'my-hook'),
+ ]);
+
+ const total = await getCustomizationTotalCount(promptsService, mcpService, workspaceService, contextService, undefined, provider);
+
+ // 4 from provider + 1 mcp = 5
+ assert.strictEqual(total, 5);
+ });
+
+ test('ignores non-prompt types from itemProvider', async () => {
+ const promptsService = createMockPromptsService({});
+ const mcpService = {
+ servers: observableValue('test', []),
+ } as unknown as IMcpService;
+ const workspaceService = createMockWorkspaceService({ filter: { sources: [PromptsStorage.local] } });
+ const contextService = createMockWorkspaceContextService([]);
+
+ const provider = createItemProvider([
+ makeItem('agent', 'a'),
+ makeItem('unknown-type', 'x'),
+ makeItem('prompt', 'p'),
+ ]);
+
+ const total = await getCustomizationTotalCount(promptsService, mcpService, workspaceService, contextService, undefined, provider);
+
+ // Only 'agent' matches the prompt types (agent, skill, instructions, hook)
+ assert.strictEqual(total, 1);
+ });
+
+ test('itemProvider returning undefined counts as zero', async () => {
+ const promptsService = createMockPromptsService({});
+ const mcpService = {
+ servers: observableValue('test', [{ id: 's1' }, { id: 's2' }]),
+ } as unknown as IMcpService;
+ const workspaceService = createMockWorkspaceService({ filter: { sources: [PromptsStorage.local] } });
+ const contextService = createMockWorkspaceContextService([]);
+
+ const provider: ICustomizationItemProvider = {
+ onDidChange: Event.None,
+ provideChatSessionCustomizations: async () => undefined,
+ };
+
+ const total = await getCustomizationTotalCount(promptsService, mcpService, workspaceService, contextService, undefined, provider);
+
+ // 0 from provider + 2 mcp = 2
+ assert.strictEqual(total, 2);
+ });
+
+ test('sums itemProvider counts with plugins and mcp', async () => {
+ const promptsService = createMockPromptsService({});
+ const mcpService = {
+ servers: observableValue('test', [{ id: 's1' }]),
+ } as unknown as IMcpService;
+ const workspaceService = createMockWorkspaceService({ filter: { sources: [PromptsStorage.local] } });
+ const contextService = createMockWorkspaceContextService([]);
+
+ const provider = createItemProvider([
+ makeItem('agent', 'a'),
+ makeItem('skill', 's'),
+ ]);
+ const agentPluginService = {
+ plugins: observableValue('test', [{ id: 'p1' }, { id: 'p2' }, { id: 'p3' }]),
+ } as unknown as IAgentPluginService;
+
+ const total = await getCustomizationTotalCount(promptsService, mcpService, workspaceService, contextService, agentPluginService, provider);
+
+ // 2 from provider + 1 mcp + 3 plugins = 6
+ assert.strictEqual(total, 6);
+ });
+ });
+
suite('data source consistency', () => {
// These tests verify that getSourceCounts uses the same data sources
// as the list widget's loadItems() — the root cause of the count mismatch bug.
diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts
index b2b5193c2257d..3ae35fd5750df 100644
--- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts
+++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts
@@ -22,7 +22,6 @@ import { IChat, ISession, isWorkspaceAgentSessionType, SessionStatus, ISessionTy
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
const ACTIVE_SESSION_STATES_KEY = 'agentSessions.activeSessionStates';
-const ACTIVE_PROVIDER_KEY = 'sessions.activeProviderId';
/**
* Persisted state for a session.
@@ -52,9 +51,6 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen
private readonly _activeSession = observableValue(this, undefined);
readonly activeSession: IObservable = this._activeSession;
- private readonly _activeProviderId = observableValue(this, undefined);
- readonly activeProviderId: IObservable = this._activeProviderId;
-
/** Tracks the pending new session so it can be restored by {@link openNewSessionView}. */
private _pendingNewSession: ISession | undefined;
private readonly isNewChatSessionContext: IContextKey;
@@ -96,11 +92,9 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen
// Save on shutdown
this._register(this.storageService.onWillSaveState(() => this._saveSessionStates()));
- // Restore or auto-select active provider
- this._initActiveProvider();
+ // Subscribe to provider changes for session type updates
this._register(this.sessionsProvidersService.onDidChangeProviders(e => {
this._onProvidersChanged(e);
- this._initActiveProvider();
this._updateSessionTypes();
}));
this._subscribeToProviders(this.sessionsProvidersService.getProviders());
@@ -129,34 +123,6 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen
}
}
- private _initActiveProvider(): void {
- const providers = this.sessionsProvidersService.getProviders();
- if (providers.length === 0) {
- return;
- }
-
- // If already set and still valid, keep it
- const current = this._activeProviderId.get();
- if (current && providers.some(p => p.id === current)) {
- return;
- }
-
- // Try to restore from storage
- const stored = this.storageService.get(ACTIVE_PROVIDER_KEY, StorageScope.PROFILE);
- if (stored && providers.some(p => p.id === stored)) {
- this._activeProviderId.set(stored, undefined);
- return;
- }
-
- // Auto-select the first (or only) provider
- this._activeProviderId.set(providers[0].id, undefined);
- }
-
- setActiveProvider(providerId: string): void {
- this._activeProviderId.set(providerId, undefined);
- this.storageService.store(ACTIVE_PROVIDER_KEY, providerId, StorageScope.PROFILE, StorageTarget.MACHINE);
- }
-
private onDidReplaceSession(from: ISession, to: ISession): void {
if (this._activeSession.get()?.sessionId === from.sessionId) {
this.setActiveSession(to);
diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts
index ae3525813c12a..64ad47c3dcfb3 100644
--- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts
+++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts
@@ -73,17 +73,6 @@ export interface ISessionsManagementService {
*/
readonly activeSession: IObservable;
- /**
- * Observable for the currently active sessions provider ID.
- * When only one provider exists, it is selected automatically.
- */
- readonly activeProviderId: IObservable;
-
- /**
- * Set the active sessions provider by ID.
- */
- setActiveProvider(providerId: string): void;
-
/**
* Select an existing session as the active session.
* Sets `isNewChatSession` context to false and opens the active chat belonging to the session.
diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts
index 5b40930d79efe..bb11f776b1b58 100644
--- a/src/vs/sessions/sessions.common.main.ts
+++ b/src/vs/sessions/sessions.common.main.ts
@@ -450,6 +450,7 @@ import './contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.js';
import './contrib/fileTreeView/browser/fileTreeView.contribution.js'; // view registration disabled; filesystem provider still needed
import './contrib/configuration/browser/configuration.contribution.js';
import './contrib/workingSet/browser/workingSet.contribution.js';
+import './contrib/browserView/browser/sessionBrowserView.contribution.js';
import './contrib/editor/browser/editor.contribution.js';
import './contrib/terminal/browser/sessionsTerminalContribution.js';
diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts
index 351500b090211..24bb3646edbdb 100644
--- a/src/vs/sessions/sessions.desktop.main.ts
+++ b/src/vs/sessions/sessions.desktop.main.ts
@@ -215,6 +215,7 @@ import './contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.js';
// Local Agent Host
import './contrib/agentHost/browser/localAgentHost.contribution.js';
import './contrib/agentHost/browser/agentSessionSettings.contribution.js';
+import './contrib/agentHost/browser/agentHostSettings.contribution.js';
// Tunnel Host (allow remote connections to local agent host)
import './contrib/tunnelHost/electron-browser/tunnelHost.contribution.js';
diff --git a/src/vs/sessions/sessions.web.main.ts b/src/vs/sessions/sessions.web.main.ts
index 6abba66db2c46..9fac1f377960c 100644
--- a/src/vs/sessions/sessions.web.main.ts
+++ b/src/vs/sessions/sessions.web.main.ts
@@ -156,6 +156,7 @@ import './contrib/remoteAgentHost/browser/remoteAgentHostTerminal.contribution.j
import './contrib/remoteAgentHost/browser/remoteAgentHost.contribution.js';
import './contrib/remoteAgentHost/browser/remoteAgentHostActions.js';
import './contrib/agentHost/browser/agentSessionSettings.contribution.js';
+import './contrib/agentHost/browser/agentHostSettings.contribution.js';
// Host filter dropdown in the titlebar (scopes the sessions list to a host)
import './contrib/remoteAgentHost/browser/hostFilter.contribution.js';
diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts
index db1ab19404444..7f7211ae2af4b 100644
--- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts
+++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts
@@ -714,10 +714,10 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA
}
async $registerChatSessionCustomizationProvider(handle: number, chatSessionType: string, metadata: IChatSessionCustomizationProviderMetadataDto, extensionId: ExtensionIdentifier): Promise {
- // In the sessions window, only the Copilot CLI harness is accepted via the
- // extension API. Other harnesses (e.g. Claude) are not shown in sessions.
+ // In the sessions window, only accept harnesses for session types that
+ // have a registered content provider (i.e., can actually run sessions).
// AHP remote servers register directly via registerExternalHarness.
- if (this._environmentService.isSessionsWindow && chatSessionType !== 'copilotcli') {
+ if (this._environmentService.isSessionsWindow && !this._chatSessionService.getContentProviderSchemes().includes(chatSessionType)) {
return;
}
diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts
index 38d3b8669c654..7d0cd091eae0c 100644
--- a/src/vs/workbench/api/common/extHostChatSessions.ts
+++ b/src/vs/workbench/api/common/extHostChatSessions.ts
@@ -32,6 +32,7 @@ import { IExtHostRpcService } from './extHostRpcService.js';
import * as typeConvert from './extHostTypeConverters.js';
import { Diagnostic } from './extHostTypeConverters.js';
import * as extHostTypes from './extHostTypes.js';
+import { isEqual } from '../../../base/common/resources.js';
type ChatSessionTiming = vscode.ChatSessionItem['timing'];
@@ -44,6 +45,9 @@ class ChatSessionInputStateImpl implements vscode.ChatSessionInputState {
readonly #onDidChangeEmitter = new Emitter();
readonly onDidChange = this.#onDidChangeEmitter.event;
+ readonly #onDidDisposeEmitter = new Emitter();
+ readonly onDidDispose = this.#onDidDisposeEmitter.event;
+
#sessionResource: vscode.Uri | undefined;
get sessionResource(): vscode.Uri | undefined {
return this.#sessionResource;
@@ -81,6 +85,12 @@ class ChatSessionInputStateImpl implements vscode.ChatSessionInputState {
_setGroups(groups: readonly vscode.ChatSessionProviderOptionGroup[]): void {
this.#groups = groups;
}
+
+ _dispose(): void {
+ this.#onDidDisposeEmitter.fire();
+ this.#onDidDisposeEmitter.dispose();
+ this.#onDidChangeEmitter.dispose();
+ }
}
// #endregion
@@ -579,6 +589,10 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio
},
dispose: () => {
isDisposed = true;
+ for (const inputState of inputStates) {
+ inputState._dispose();
+ }
+ inputStates.clear();
disposables.dispose();
},
});
@@ -652,6 +666,11 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio
);
if (inputState instanceof ChatSessionInputStateImpl) {
+ // Dispose any previous input states for this session resource
+ if (controllerData) {
+ this._disposeInputStatesForResource(controllerData.inputStates, sessionResource);
+ }
+
if (isUntitledChatSession(sessionResource)) {
inputState.untitledSessionResource = sessionResource;
} else {
@@ -805,15 +824,22 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio
}
async $disposeChatSessionContent(providerHandle: number, sessionResource: UriComponents): Promise {
- const entry = this._extHostChatSessions.get(URI.revive(sessionResource));
+ const resource = URI.revive(sessionResource);
+ const entry = this._extHostChatSessions.get(resource);
if (!entry) {
this._logService.warn(`No chat session found for resource: ${sessionResource}`);
return;
}
+ // Dispose input states associated with this session
+ const controllerData = this.getChatSessionItemController(resource.scheme);
+ if (controllerData) {
+ this._disposeInputStatesForResource(controllerData.inputStates, resource);
+ }
+
entry.disposeCts.cancel();
entry.sessionObj.sessionDisposables.dispose();
- this._extHostChatSessions.delete(URI.revive(sessionResource));
+ this._extHostChatSessions.delete(resource);
}
async $invokeChatSessionRequestHandler(handle: number, sessionResource: UriComponents, request: IChatAgentRequest, history: any[], token: CancellationToken): Promise {
@@ -882,6 +908,16 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio
return undefined;
}
+ private _disposeInputStatesForResource(inputStates: Set, resource: URI): void {
+ for (const inputState of inputStates) {
+ const inputResource = inputState.sessionResource ?? inputState.untitledSessionResource;
+ if (inputResource && isEqual(resource, inputResource)) {
+ inputState._dispose();
+ inputStates.delete(inputState);
+ }
+ }
+ }
+
private _createInputStateFromOptions(
groups: readonly vscode.ChatSessionProviderOptionGroup[],
sessionOptions?: ReadonlyArray<{ optionId: string; value: string }>,
@@ -924,6 +960,11 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio
);
if (result) {
if (result instanceof ChatSessionInputStateImpl) {
+ // Dispose any previous input states for this session resource
+ if (sessionResource && controllerData) {
+ this._disposeInputStatesForResource(controllerData.inputStates, sessionResource);
+ }
+
if (sessionResource && isUntitledChatSession(sessionResource)) {
result.untitledSessionResource = sessionResource;
} else if (sessionResource) {
@@ -1193,6 +1234,9 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio
}
if (inputState instanceof ChatSessionInputStateImpl && sessionResource) {
+ // Dispose any previous input states for this session resource
+ this._disposeInputStatesForResource(controllerData.inputStates, sessionResource);
+
if (isUntitledChatSession(sessionResource)) {
inputState.untitledSessionResource = sessionResource;
} else {
diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts
index 6b198c848b138..cf9b6123a2de3 100644
--- a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts
+++ b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts
@@ -68,7 +68,8 @@ export const enum AccessibilityVerbositySettingId {
Walkthrough = 'accessibility.verbosity.walkthrough',
SourceControl = 'accessibility.verbosity.sourceControl',
Find = 'accessibility.verbosity.find',
- SessionsChat = 'accessibility.verbosity.sessionsChat'
+ SessionsChat = 'accessibility.verbosity.sessionsChat',
+ ChatQuestionCarousel = 'accessibility.verbosity.chatQuestionCarousel'
}
const baseVerbosityProperty: IConfigurationPropertySchema = {
@@ -210,6 +211,10 @@ const configuration: IConfigurationNode = {
description: localize('verbosity.sessionsChat', 'Provide information about how to access the Agents app accessibility help menu when the chat input is focused.'),
...baseVerbosityProperty
},
+ [AccessibilityVerbositySettingId.ChatQuestionCarousel]: {
+ description: localize('verbosity.chatQuestionCarousel', 'Provide information about how to navigate and interact with the chat question carousel, including how to focus the terminal when applicable.'),
+ ...baseVerbosityProperty
+ },
'accessibility.signalOptions.volume': {
'description': localize('accessibility.signalOptions.volume', "The volume of the sounds in percent (0-100)."),
'type': 'number',
diff --git a/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts b/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts
index 77cc549c182a7..d20b820a2ee9a 100644
--- a/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts
+++ b/src/vs/workbench/contrib/browserView/common/browserEditorInput.ts
@@ -22,6 +22,7 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet
import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js';
import { LRUCachedFunction } from '../../../../base/common/cache.js';
import { DisposableStore } from '../../../../base/common/lifecycle.js';
+import { Emitter, Event } from '../../../../base/common/event.js';
const LOADING_SPINNER_SVG = (color: string | undefined) => `