diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md index d29daad857880..d1db115f9043d 100644 --- a/.github/skills/sessions/SKILL.md +++ b/.github/skills/sessions/SKILL.md @@ -33,9 +33,7 @@ Then read the relevant spec for the area you are changing (see table below). If - **Modifying workbench code**: Prefer extending/wrapping workbench classes in the sessions layer over modifying shared workbench components. - **Timeouts as fixes**: Never use `setTimeout`/`disposableTimeout`/arbitrary delays to fix bugs or implement behaviour. They are race-prone guesses that mask the real ordering/state problem. Drive logic off deterministic signals instead — observables (`autorun`/`derived`), explicit events (`onDidChange*`), lifecycle phases, or awaiting the actual async operation. - **Stashed state read back later (side-channels)**: Never stash a value on a service during one method call and read it back from a separate query later, assuming it is still valid (e.g. a `Set`/flag set in `openSession` and consumed by a `shouldX()` pull-API). This is fragile temporal coupling. Instead, make it reactive state that is set **atomically together with its source of truth** and consumed reactively. Example: per-activation intent like "open in background / preserve focus" is exposed as an `IObservable` set in the **same transaction** as `activeSession` (via a single internal setter so it can never go stale), and read with `.read(reader)` in the consumer's `autorun` — never via a consume-once getter. -- **Blocking on a "pending/waiting" state instead of creating + upgrading**: When an entity (e.g. a draft session) depends on something that registers asynchronously, don't withhold creation behind a pending/waiting state. Prefer creating immediately with the best available data, then **replace/upgrade** it once the awaited dependency arrives (driven by an `onDidChange*`/observable signal), cancelling the upgrade if the user changes the inputs meanwhile. Do **not** bound the upgrade with a timeout or even a lifecycle milestone like `LifecyclePhase.Eventually` — an agent host connects lazily and can surface its session types after `Eventually` has already fired, which would lock in the wrong fallback. Let the upgrade listener live for the consumer's lifetime instead. -- **Persisting a picker value only on manual selection**: A picker that writes storage only when the user actively changes the dropdown will lose any auto-selected/defaulted value on reload (the stored preference is empty, so it falls back to a default). Persist on **every** change of the underlying value — exactly like a normal picker — so the last effective value always survives reload. Example: `SessionTypePicker` writes `{ providerId, sessionTypeId }` from both the manual pick handler and the active-session `refresh`, through the single `_writeStoredPick` persistence point. -- **Pre-validating a stored preference against not-yet-ready state in an event handler**: Don't re-check a restored preference against currently-available data (e.g. validating the stored session-type pick against `getSessionTypesForFolder` inside `onDidSelectWorkspace`) and null it out on mismatch. During reload the awaited provider hasn't registered yet, so a perfectly valid preference is discarded and the wrong default is locked in. Pass the preference straight through to the single place that reconciles preference-vs-availability (which creates now and upgrades later). +- **Blocking on a "pending/waiting" state instead of creating + upgrading**: When an entity (e.g. a draft session) depends on something that registers asynchronously, don't withhold creation behind a pending/waiting state. Prefer creating immediately with the best available data, then **replace/upgrade** it once the awaited dependency arrives (driven by an `onDidChange*`/observable signal), cancelling the upgrade if the user changes the inputs meanwhile. Do **not** bound the upgrade with a timeout or even a lifecycle milestone like `LifecyclePhase.Eventually` — an agent host connects lazily and can surface its session types arbitrarily late, which would lock in the wrong fallback. Let the upgrade listener live for the consumer's lifetime instead. ## Capturing Feedback (meta-rule) diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index 77d7f19815a40..63deafddf8a03 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -43,7 +43,6 @@ "applicationinsights": "^2.9.7", "best-effort-json-parser": "^1.2.1", "diff": "^8.0.3", - "dompurify": "^3.4.8", "express": "^5.2.1", "ignore": "^7.0.5", "isbinaryfile": "^5.0.4", @@ -6405,13 +6404,6 @@ "minipass": "^4.0.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -9708,15 +9700,6 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, - "node_modules/dompurify": { - "version": "3.4.8", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", - "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/domutils": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 3d4116a3db53f..c4a2c922b314e 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -3074,6 +3074,12 @@ "icon": "$(git-pull-request)", "category": "GitHub Pull Request" }, + { + "command": "github.copilot.chat.cloudSessions.createPullRequestForTask", + "title": "%github.copilot.command.cloudSessions.createPullRequestForTask.title%", + "icon": "$(git-pull-request-create)", + "category": "GitHub Pull Request" + }, { "command": "github.copilot.chat.cloudSessions.openRepository", "title": "%github.copilot.command.cloudSessions.openRepository.title%", @@ -5403,6 +5409,11 @@ "command": "github.copilot.chat.checkoutPullRequestReroute", "when": "chatSessionType == copilot-cloud-agent && !github.vscode-pull-request-github.activated && gitOpenRepositoryCount != 0", "group": "navigation@0" + }, + { + "command": "github.copilot.chat.cloudSessions.createPullRequestForTask", + "when": "chatSessionType == copilot-cloud-agent && github.copilot.chat.cloudTaskCanCreatePullRequest && !isSessionsWindow", + "group": "navigation@0" } ], "agents/changes/actions/primary": [ @@ -7024,7 +7035,6 @@ "applicationinsights": "^2.9.7", "best-effort-json-parser": "^1.2.1", "diff": "^8.0.3", - "dompurify": "^3.4.8", "express": "^5.2.1", "ignore": "^7.0.5", "isbinaryfile": "^5.0.4", diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index fcffd077fcd3c..620a86a2f07a5 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -487,6 +487,7 @@ "github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR": "Create Pull Request", "github.copilot.chat.createDraftPullRequestCopilotCLIAgentSession.createDraftPR": "Create Draft Pull Request", "github.copilot.command.checkoutPullRequestReroute.title": "Checkout", + "github.copilot.command.cloudSessions.createPullRequestForTask.title": "Create Pull Request", "github.copilot.command.cloudSessions.openRepository.title": "Browse repositories...", "github.copilot.command.cloudSessions.clearCaches.title": "Clear Cloud Agent Caches", "github.copilot.command.applyCopilotCLIAgentSessionChanges": "Apply Changes to Workspace", diff --git a/extensions/copilot/src/extension/chatSessions/claude/common/claudeSessionStateService.ts b/extensions/copilot/src/extension/chatSessions/claude/common/claudeSessionStateService.ts index b0ff2d44757b7..31fc50d67f6f1 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/common/claudeSessionStateService.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/common/claudeSessionStateService.ts @@ -24,6 +24,7 @@ export interface SessionState { folderInfo: ClaudeFolderInfo | undefined; usageHandler: UsageHandler | undefined; reasoningEffort: EffortLevel | undefined; + contextSize: number | undefined; traceContext: TraceContext | undefined; turnId: string | undefined; } @@ -106,6 +107,18 @@ export interface IClaudeSessionStateService { */ setReasoningEffortForSession(sessionId: string, effort: EffortLevel | undefined): void; + /** + * Gets the context size for a session (user's per-request selection from the model picker). + * When set, the proxy reports this value as the endpoint's modelMaxPromptTokens so internal + * accounting reflects the chosen tier. + */ + getContextSizeForSession(sessionId: string): number | undefined; + + /** + * Sets the context size for a session. + */ + setContextSizeForSession(sessionId: string, contextSize: number | undefined): void; + /** * Gets the OTel trace context for a session (used to parent chat spans to invoke_agent). */ diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts index 4268d89af068d..2534a5fcbd0f9 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts @@ -8,7 +8,7 @@ import type * as vscode from 'vscode'; import { IEndpointProvider } from '../../../../platform/endpoint/common/endpointProvider'; import { ILogService } from '../../../../platform/log/common/logService'; import { IChatEndpoint } from '../../../../platform/networking/common/networking'; -import { formatPricingLabel, getModelCapabilitiesDescription } from '../../../conversation/common/languageModelAccess'; +import { formatPricingLabel, formatTokenCount, getModelCapabilitiesDescription } from '../../../conversation/common/languageModelAccess'; import { createServiceIdentifier } from '../../../../util/common/services'; import { Emitter } from '../../../../util/vs/base/common/event'; import { Disposable } from '../../../../util/vs/base/common/lifecycle'; @@ -17,6 +17,7 @@ import { tryParseClaudeModelId } from './claudeModelId'; import type { EffortLevel } from '@anthropic-ai/claude-agent-sdk'; export const CLAUDE_REASONING_EFFORT_PROPERTY = 'reasoningEffort'; +export const CLAUDE_CONTEXT_SIZE_PROPERTY = 'contextSize'; export interface IClaudeCodeModels { readonly _serviceBrand: undefined; @@ -214,33 +215,57 @@ export function pickReasoningEffort(endpoint: IChatEndpoint | undefined, request } function buildConfigurationSchema(endpoint: IChatEndpoint): vscode.LanguageModelConfigurationSchema | undefined { + const properties: Record[string]> = {}; + + // Thinking effort const effortLevels = endpoint.supportsReasoningEffort?.filter( (level): level is typeof SUPPORTED_EFFORT_LEVELS[number] => (SUPPORTED_EFFORT_LEVELS as readonly string[]).includes(level) ); - if (!effortLevels) { - return; + if (effortLevels && effortLevels.length > 0) { + const defaultEffort = effortLevels.includes('high') ? 'high' : undefined; + properties[CLAUDE_REASONING_EFFORT_PROPERTY] = { + type: 'string', + title: l10n.t('Thinking Effort'), + enum: effortLevels, + enumItemLabels: effortLevels.map(level => level.charAt(0).toUpperCase() + level.slice(1)), + enumDescriptions: effortLevels.map(level => { + switch (level) { + case 'low': return l10n.t('Faster responses with less reasoning'); + case 'medium': return l10n.t('Balanced reasoning and speed'); + case 'high': return l10n.t('Greater reasoning depth but slower'); + } + }), + default: defaultEffort, + group: 'navigation', + }; } - const defaultEffort = effortLevels.includes('high') ? 'high' : undefined; - - return { - properties: { - [CLAUDE_REASONING_EFFORT_PROPERTY]: { - type: 'string', - title: l10n.t('Thinking Effort'), - enum: effortLevels, - enumItemLabels: effortLevels.map(level => level.charAt(0).toUpperCase() + level.slice(1)), - enumDescriptions: effortLevels.map(level => { - switch (level) { - case 'low': return l10n.t('Faster responses with less reasoning'); - case 'medium': return l10n.t('Balanced reasoning and speed'); - case 'high': return l10n.t('Greater reasoning depth but slower'); - } - }), - default: defaultEffort, - group: 'navigation', - } - } - }; + // Context size — only when CAPI provides a default context max, indicating + // a meaningful distinction between default and long context tiers. + const pricing = endpoint.tokenPricing; + const defaultContextMax = pricing?.default.contextMax; + const fullMax = endpoint.modelMaxPromptTokens; + if (defaultContextMax && defaultContextMax < fullMax) { + const hasLongContextSurcharge = !!pricing?.longContext; + properties[CLAUDE_CONTEXT_SIZE_PROPERTY] = { + type: 'number', + title: l10n.t('Context Size'), + enum: [defaultContextMax, fullMax], + enumItemLabels: [formatTokenCount(defaultContextMax), formatTokenCount(fullMax)], + enumDescriptions: [ + l10n.t('Default'), + hasLongContextSurcharge + ? l10n.t('Longer sessions') + : l10n.t('Longer sessions without compaction'), + ], + default: defaultContextMax, + group: 'tokens', + }; + } + + if (Object.keys(properties).length === 0) { + return undefined; + } + return { properties }; } diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts index 6bab2908a673e..2cffe861ceead 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts @@ -191,6 +191,17 @@ export class ClaudeLanguageModelServer extends Disposable { tokenSource.cancel(); }); + // If the user picked a context tier in the model picker, surface that + // value directly as the prompt token budget. CAPI's `tokenPricing.default.contextMax` + // is documented as a prompt-token limit (matching `endpoint.modelMaxPromptTokens`), + // and CAPI bills the tier based on actual prompt size, so no other signaling is + // required. Clamp to the default budget so the override never lowers it. + const sessionContextSize = sessionId ? this.sessionStateService.getContextSizeForSession(sessionId) : undefined; + const defaultPromptBudget = DEFAULT_MAX_TOKENS - DEFAULT_MAX_OUTPUT_TOKENS; + const modelMaxPromptTokens = sessionContextSize !== undefined + ? Math.max(defaultPromptBudget, sessionContextSize) + : defaultPromptBudget; + const endpointRequestBody = requestBody as IEndpointBody; const streamingEndpoint = this.instantiationService.createInstance( ClaudeStreamingPassThroughEndpoint, @@ -200,7 +211,7 @@ export class ClaudeLanguageModelServer extends Disposable { headers, 'vscode_claude_code', { - modelMaxPromptTokens: DEFAULT_MAX_TOKENS - DEFAULT_MAX_OUTPUT_TOKENS, + modelMaxPromptTokens, maxOutputTokens: DEFAULT_MAX_OUTPUT_TOKENS }, sessionId diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeSessionStateService.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeSessionStateService.ts index ed99693d2a5cf..d7a690b554275 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeSessionStateService.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeSessionStateService.ts @@ -47,6 +47,7 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess folderInfo: existing?.folderInfo, usageHandler: existing?.usageHandler, reasoningEffort: existing?.reasoningEffort, + contextSize: existing?.contextSize, traceContext: existing?.traceContext, turnId: existing?.turnId, }); @@ -69,6 +70,7 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess folderInfo: existing?.folderInfo, usageHandler: existing?.usageHandler, reasoningEffort: existing?.reasoningEffort, + contextSize: existing?.contextSize, traceContext: existing?.traceContext, turnId: existing?.turnId, }); @@ -88,6 +90,7 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess folderInfo: existing?.folderInfo, usageHandler: existing?.usageHandler, reasoningEffort: existing?.reasoningEffort, + contextSize: existing?.contextSize, traceContext: existing?.traceContext, turnId: existing?.turnId, }); @@ -109,6 +112,7 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess folderInfo, usageHandler: existing?.usageHandler, reasoningEffort: existing?.reasoningEffort, + contextSize: existing?.contextSize, traceContext: existing?.traceContext, turnId: existing?.turnId, }); @@ -128,6 +132,7 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess folderInfo: existing?.folderInfo, usageHandler: handler, reasoningEffort: existing?.reasoningEffort, + contextSize: existing?.contextSize, traceContext: existing?.traceContext, turnId: existing?.turnId, }); @@ -149,6 +154,29 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess folderInfo: existing?.folderInfo, usageHandler: existing?.usageHandler, reasoningEffort: effort, + contextSize: existing?.contextSize, + traceContext: existing?.traceContext, + turnId: existing?.turnId, + }); + } + + getContextSizeForSession(sessionId: string): number | undefined { + return this._sessionState.get(sessionId)?.contextSize; + } + + setContextSizeForSession(sessionId: string, contextSize: number | undefined): void { + const existing = this._sessionState.get(sessionId); + if (existing?.contextSize === contextSize) { + return; + } + this._sessionState.set(sessionId, { + modelId: existing?.modelId, + permissionMode: existing?.permissionMode ?? 'acceptEdits', + capturingToken: existing?.capturingToken, + folderInfo: existing?.folderInfo, + usageHandler: existing?.usageHandler, + reasoningEffort: existing?.reasoningEffort, + contextSize, traceContext: existing?.traceContext, turnId: existing?.turnId, }); @@ -167,6 +195,7 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess folderInfo: existing?.folderInfo, usageHandler: existing?.usageHandler, reasoningEffort: existing?.reasoningEffort, + contextSize: existing?.contextSize, traceContext, turnId: existing?.turnId, }); @@ -185,6 +214,7 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess folderInfo: existing?.folderInfo, usageHandler: existing?.usageHandler, reasoningEffort: existing?.reasoningEffort, + contextSize: existing?.contextSize, traceContext: existing?.traceContext, turnId, }); diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeCodeModels.spec.ts b/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeCodeModels.spec.ts index 8640e2d2797f7..1aa7532ba02bb 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeCodeModels.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeCodeModels.spec.ts @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import type * as vscode from 'vscode'; import { IEndpointProvider } from '../../../../../platform/endpoint/common/endpointProvider'; -import { IChatEndpoint } from '../../../../../platform/networking/common/networking'; +import { IChatEndpoint, IChatEndpointTokenPricing } from '../../../../../platform/networking/common/networking'; import { Emitter } from '../../../../../util/vs/base/common/event'; import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle'; import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation'; @@ -26,6 +26,8 @@ function createMockEndpoint(overrides: { apiType?: string; modelProvider?: string; supportsReasoningEffort?: string[]; + tokenPricing?: IChatEndpointTokenPricing; + modelMaxPromptTokens?: number; }): IChatEndpoint { const isAnthropic = overrides.modelProvider === undefined || overrides.modelProvider === 'Anthropic'; return { @@ -47,7 +49,8 @@ function createMockEndpoint(overrides: { isFallback: false, policy: 'enabled', urlOrRequestMetadata: 'mock://endpoint', - modelMaxPromptTokens: 128000, + modelMaxPromptTokens: overrides.modelMaxPromptTokens ?? 128000, + tokenPricing: overrides.tokenPricing, tokenizer: 'cl100k_base', acquireTokenizer: () => ({ encode: () => [], free: () => { } }) as any, processResponseFromChatEndpoint: () => Promise.resolve({} as any), @@ -325,6 +328,110 @@ describe('ClaudeCodeModels', () => { expect(schema.properties?.['reasoningEffort'].enum).toEqual(['high']); expect(schema.properties!['reasoningEffort'].default).toBe('high'); }); + + it('includes contextSize when endpoint pricing exposes a default context max below modelMaxPromptTokens', async () => { + const { service } = createServiceWithRefreshableEndpoints([ + createMockEndpoint({ + model: 'claude-sonnet-4-model', + name: 'Claude Sonnet 4', + family: 'claude-sonnet-4', + modelMaxPromptTokens: 1_000_000, + tokenPricing: { + default: { inputPrice: 3, outputPrice: 15, cacheReadTokenPrice: 0.3, contextMax: 200_000 }, + longContext: { inputPrice: 6, outputPrice: 22.5, cacheReadTokenPrice: 0.6 }, + }, + }), + ]); + const { lm, getCapturedProvider } = createMockLm(); + + const info = await getProviderInfo(service, lm, getCapturedProvider); + const schema = info[0].configurationSchema!; + expect(schema.properties?.['contextSize']).toEqual({ + type: 'number', + title: 'Context Size', + enum: [200_000, 1_000_000], + enumItemLabels: ['200K', '1M'], + enumDescriptions: ['Default', 'Longer sessions'], + default: 200_000, + group: 'tokens', + }); + }); + + it('omits contextSize when pricing has no default context max', async () => { + const { service } = createServiceWithRefreshableEndpoints([ + createMockEndpoint({ + model: 'claude-sonnet-4-model', + name: 'Claude Sonnet 4', + family: 'claude-sonnet-4', + modelMaxPromptTokens: 1_000_000, + tokenPricing: { + default: { inputPrice: 3, outputPrice: 15, cacheReadTokenPrice: 0.3 }, + }, + }), + ]); + const { lm, getCapturedProvider } = createMockLm(); + + const info = await getProviderInfo(service, lm, getCapturedProvider); + expect(info[0].configurationSchema).toBeUndefined(); + }); + + it('omits contextSize when default context max equals modelMaxPromptTokens', async () => { + const { service } = createServiceWithRefreshableEndpoints([ + createMockEndpoint({ + model: 'claude-sonnet-4-model', + name: 'Claude Sonnet 4', + family: 'claude-sonnet-4', + modelMaxPromptTokens: 200_000, + tokenPricing: { + default: { inputPrice: 3, outputPrice: 15, cacheReadTokenPrice: 0.3, contextMax: 200_000 }, + }, + }), + ]); + const { lm, getCapturedProvider } = createMockLm(); + + const info = await getProviderInfo(service, lm, getCapturedProvider); + expect(info[0].configurationSchema).toBeUndefined(); + }); + + it('describes the long-context option without surcharge when there is no long-context pricing tier', async () => { + const { service } = createServiceWithRefreshableEndpoints([ + createMockEndpoint({ + model: 'claude-sonnet-4-model', + name: 'Claude Sonnet 4', + family: 'claude-sonnet-4', + modelMaxPromptTokens: 1_000_000, + tokenPricing: { + default: { inputPrice: 3, outputPrice: 15, cacheReadTokenPrice: 0.3, contextMax: 200_000 }, + }, + }), + ]); + const { lm, getCapturedProvider } = createMockLm(); + + const info = await getProviderInfo(service, lm, getCapturedProvider); + const schema = info[0].configurationSchema!; + expect(schema.properties?.['contextSize'].enumDescriptions).toEqual(['Default', 'Longer sessions without compaction']); + }); + + it('includes both reasoningEffort and contextSize when supported', async () => { + const { service } = createServiceWithRefreshableEndpoints([ + createMockEndpoint({ + model: 'claude-sonnet-4-model', + name: 'Claude Sonnet 4', + family: 'claude-sonnet-4', + supportsReasoningEffort: ['low', 'medium', 'high'], + modelMaxPromptTokens: 1_000_000, + tokenPricing: { + default: { inputPrice: 3, outputPrice: 15, cacheReadTokenPrice: 0.3, contextMax: 200_000 }, + longContext: { inputPrice: 6, outputPrice: 22.5, cacheReadTokenPrice: 0.6 }, + }, + }), + ]); + const { lm, getCapturedProvider } = createMockLm(); + + const info = await getProviderInfo(service, lm, getCapturedProvider); + const schema = info[0].configurationSchema!; + expect(Object.keys(schema.properties ?? {})).toEqual(['reasoningEffort', 'contextSize']); + }); }); describe('resolveEndpoint with ParsedClaudeModelId', () => { diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeSessionStateService.spec.ts b/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeSessionStateService.spec.ts index df9e51dd3ca21..942954f794830 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeSessionStateService.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeSessionStateService.spec.ts @@ -268,6 +268,57 @@ describe('ClaudeSessionStateService', () => { }); }); + describe('getContextSizeForSession', () => { + it('should return undefined when no context size is set', () => { + assert.strictEqual(service.getContextSizeForSession('session-1'), undefined); + }); + + it('should return the set context size', () => { + service.setContextSizeForSession('session-1', 200_000); + assert.strictEqual(service.getContextSizeForSession('session-1'), 200_000); + }); + + it('should return different sizes for different sessions', () => { + service.setContextSizeForSession('session-1', 200_000); + service.setContextSizeForSession('session-2', 1_000_000); + + assert.strictEqual(service.getContextSizeForSession('session-1'), 200_000); + assert.strictEqual(service.getContextSizeForSession('session-2'), 1_000_000); + }); + }); + + describe('setContextSizeForSession', () => { + it('should allow setting a context size', () => { + service.setContextSizeForSession('session-1', 1_000_000); + assert.strictEqual(service.getContextSizeForSession('session-1'), 1_000_000); + }); + + it('should allow clearing a context size', () => { + service.setContextSizeForSession('session-1', 1_000_000); + service.setContextSizeForSession('session-1', undefined); + assert.strictEqual(service.getContextSizeForSession('session-1'), undefined); + }); + + it('should preserve other state when setting context size', () => { + service.setModelIdForSession('session-1', OPUS_4); + service.setReasoningEffortForSession('session-1', 'high'); + + service.setContextSizeForSession('session-1', 1_000_000); + + assert.strictEqual(service.getModelIdForSession('session-1'), OPUS_4); + assert.strictEqual(service.getReasoningEffortForSession('session-1'), 'high'); + }); + + it('should not fire onDidChangeSessionState event', () => { + const events: SessionStateChangeEvent[] = []; + service.onDidChangeSessionState(e => events.push(e)); + + service.setContextSizeForSession('session-1', 1_000_000); + + assert.strictEqual(events.length, 0); + }); + }); + describe('dispose', () => { it('should clear session state on dispose', () => { service.setModelIdForSession('session-1', OPUS_4); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts index b80ff225575f3..33d966f03ac3f 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts @@ -26,7 +26,7 @@ import { IInstantiationService } from '../../../util/vs/platform/instantiation/c import { ClaudeFolderInfo } from '../claude/common/claudeFolderInfo'; import { ClaudeSessionUri } from '../claude/common/claudeSessionUri'; import { ClaudeAgentManager } from '../claude/node/claudeCodeAgent'; -import { CLAUDE_REASONING_EFFORT_PROPERTY, IClaudeCodeModels, pickReasoningEffort } from '../claude/node/claudeCodeModels'; +import { CLAUDE_CONTEXT_SIZE_PROPERTY, CLAUDE_REASONING_EFFORT_PROPERTY, IClaudeCodeModels, pickReasoningEffort } from '../claude/node/claudeCodeModels'; import { IClaudeCodeSdkService } from '../claude/node/claudeCodeSdkService'; import { parseClaudeModelId } from '../claude/node/claudeModelId'; import { IClaudeSessionStateService } from '../claude/common/claudeSessionStateService'; @@ -177,6 +177,10 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco const reasoningEffort = pickReasoningEffort(endpoint, typeof rawReasoningEffort === 'string' ? rawReasoningEffort : undefined); this.sessionStateService.setReasoningEffortForSession(effectiveSessionId, reasoningEffort); + const rawContextSize = request.modelConfiguration?.[CLAUDE_CONTEXT_SIZE_PROPERTY]; + const contextSize = typeof rawContextSize === 'number' && rawContextSize > 0 ? rawContextSize : undefined; + this.sessionStateService.setContextSizeForSession(effectiveSessionId, contextSize); + // Set usage handler to report token usage for context window widget this.sessionStateService.setUsageHandlerForSession(effectiveSessionId, (usage) => { stream.usage(usage); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionContentBuilder.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionContentBuilder.ts index 7dc312bd5422b..94edfe21c0cf7 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionContentBuilder.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionContentBuilder.ts @@ -170,7 +170,7 @@ export class ChatSessionContentBuilder { * (request = the turn prompt; response = a markdown summary derived from events scoped * to that turn). This does NOT call the SSE log parser — events are typed. * - * `pullArtifact` is shown as a header card only when the task happens to have a PR. + * `pullRequest` is shown as a header card only when the task happens to have a PR. */ public buildTaskHistory( task: AgentTaskGetResponse, diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts index 32bd45be7a8fc..1a19a9b908e96 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts @@ -40,7 +40,7 @@ import { CopilotCloudGitOperationsManager } from './copilotCloudGitOperationsMan import { ChatSessionContentBuilder, SessionResponseLogChunk } from './copilotCloudSessionContentBuilder'; import { StreamBaseline, TaskTurnStreamer } from './taskTurnStreamer'; import { JobsApiBackend } from './jobsApiBackend'; -import { TaskApiBackend, TaskApiHttpClient } from './taskApiBackend'; +import { parseRepoFromTaskUrl, TaskApiBackend, TaskApiHttpClient } from './taskApiBackend'; import { resolvePullArtifact } from './pullArtifactResolver'; import { IPullRequestFileChangesService } from './pullRequestFileChangesService'; import MarkdownIt = require('markdown-it'); @@ -163,6 +163,9 @@ const ACTIVE_SESSION_POLL_INTERVAL_MS = 5 * 1000; // 5 seconds const SEEN_DELEGATION_PROMPT_KEY = 'seenDelegationPromptBefore'; const OPEN_REPOSITORY_COMMAND_ID = 'github.copilot.chat.cloudSessions.openRepository'; const CLEAR_CACHES_COMMAND_ID = 'github.copilot.chat.cloudSessions.clearCaches'; +const CREATE_PULL_REQUEST_FOR_TASK_COMMAND_ID = 'github.copilot.chat.cloudSessions.createPullRequestForTask'; +/** Context key gating the chat-input "Create pull request" toolbar action: true while the viewed cloud task is settled and has no PR yet. */ +const CAN_CREATE_PULL_REQUEST_CONTEXT_KEY = 'github.copilot.chat.cloudTaskCanCreatePullRequest'; const USER_SELECTED_REPOS_KEY = 'userSelectedRepositories'; const USER_SELECTED_REPOS_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 1 week @@ -291,6 +294,9 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C })[] | undefined; private activeSessionIds: Set = new Set(); private activeSessionPollingInterval: ReturnType | undefined; + // Task ids with an in-flight "Create pull request" toolbar request, used to guard against + // re-entrant invocations (e.g. rapid double-clicks) that would otherwise submit duplicate PRs. + private readonly _createPullRequestInFlightTaskIds = new Set(); private readonly plainTextRenderer = new PlainTextRenderer(); private readonly gitOperationsManager = new CopilotCloudGitOperationsManager(this.logService, this._gitService, this._gitExtensionService); @@ -599,6 +605,8 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C this.refresh(); this._onDidChangeChatSessionProviderOptions.fire(); })); + + this._register(vscode.commands.registerCommand(CREATE_PULL_REQUEST_FOR_TASK_COMMAND_ID, (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => this.handleCreatePullRequestForTaskCommand(sessionItemOrResource))); } private getRefreshIntervalTime(hasHistoricalSessions: boolean): number { @@ -1349,6 +1357,9 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C } async provideChatSessionContent(resource: Uri, token: vscode.CancellationToken): Promise { + // Reset the input-toolbar "Create pull request" gate; provideTaskChatSessionContent + // re-enables it only for a settled, PR-less task. + this.setCanCreatePullRequestContext(false); const identity = this._backend.parseSessionId(resource); // Task-keyed (v2): render exactly one task as a turn-by-turn thread from `task.sessions[]`. @@ -1498,14 +1509,23 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C // Best-effort PR decoration for the header card. let pullRequest: PullRequestSearchItem | undefined; if (taskContent.pullArtifact) { - pullRequest = await resolvePullArtifact(this._octoKitService, this.logService, taskContent.pullArtifact); + pullRequest = await resolvePullArtifact(this._octoKitService, this.logService, taskContent.pullArtifact, [...(taskContent.task.sessions || [])]); } const storedReferences: Promise = Promise.resolve([...(this.sessionReferencesMap.get(resource) ?? [])]); const builder = new ChatSessionContentBuilder(CopilotCloudSessionsProvider.TYPE, this._gitService, this.logService); - const history = await builder.buildTaskHistory(taskContent.task, events, pullRequest, storedReferences); + const history = await builder.buildTaskHistory( + taskContent.task, + events, + pullRequest, + storedReferences, + ); const latestTurn = taskContent.task.sessions?.[taskContent.task.sessions.length - 1]; + const isSettled = !!latestTurn?.state && latestTurn.state !== 'in_progress' && latestTurn.state !== 'queued'; + // Gate the chat-input "Create pull request" toolbar action: offer it only while this + // settled task has produced no pull request yet. + this.setCanCreatePullRequestContext(isSettled && !taskContent.pullArtifact); const activeResponseCallback = latestTurn && (latestTurn.state === 'in_progress' || latestTurn.state === 'queued') ? this._createTaskStreamCallback(taskId, { mode: 'current', seedEventIds: new Set(events.map(e => e.id)) }) : undefined; @@ -1977,13 +1997,6 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C } private async handleConfirmationData(request: vscode.ChatRequest, stream: vscode.ChatResponseStream, context: vscode.ChatContext, token: vscode.CancellationToken) { - if (!request.prompt || request.prompt.indexOf(':') === -1) { - this.logService.error('Invalid confirmation prompt format.'); - return {}; - } - - // Parse out the button selected by the user - const selection = (request.prompt?.split(':')[0] || '').trim().toUpperCase(); const metadata: unknown = request.acceptedConfirmationData?.[0]?.metadata || request.rejectedConfirmationData?.[0]?.metadata; try { validateMetadata(metadata); @@ -1992,6 +2005,16 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C return {}; } + // Delegation flow: the prompt is expected to be ": " + // (the workbench builds this from the clicked confirmation button + message text). + if (!request.prompt || request.prompt.indexOf(':') === -1) { + this.logService.error('Invalid confirmation prompt format.'); + return {}; + } + + // Parse out the button selected by the user (delegation flow) + const selection = (request.prompt?.split(':')[0] || '').trim().toUpperCase(); + // -- Process each button press in order of precedence if (!selection || selection === this.CANCEL.toUpperCase() || token.isCancellationRequested) { @@ -2068,6 +2091,91 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C } } + /** + * Handle a click on the chat input "Create pull request" toolbar action (contributed to + * `chat/input/editing/sessionToolbar` and registered as + * {@link CREATE_PULL_REQUEST_FOR_TASK_COMMAND_ID}). The toolbar passes the session resource + * as the first argument, from which we resolve the task id. Calls the Task API's `create-pr` + * endpoint, then refreshes the session so the next render shows a proper PR card (resolved + * via `pullArtifact`) and hides the toolbar action. + */ + private async handleCreatePullRequestForTaskCommand(sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri): Promise { + const backend = this._backend; + if (backend.kind !== 'task') { + vscode.window.showWarningMessage(vscode.l10n.t('Creating a pull request from a task is only supported on the v2 cloud agent backend.')); + return; + } + const resource = sessionItemOrResource instanceof vscode.Uri ? sessionItemOrResource : sessionItemOrResource?.resource; + const taskId = resource ? SessionIdForTask.parse(resource)?.taskId : undefined; + if (!taskId) { + this.logService.error('[handleCreatePullRequestForTaskCommand] Could not resolve task id from the session resource.'); + return; + } + + // Re-entrancy guard: ignore repeat invocations while a create-PR request for this task is + // still in flight (e.g. the toolbar action triggered again before the first call settled) + // so we don't submit duplicate PRs. Also hide the toolbar action immediately; it is + // restored on failure below, and on success `refresh()` re-evaluates the context key. + if (this._createPullRequestInFlightTaskIds.has(taskId)) { + return; + } + this._createPullRequestInFlightTaskIds.add(taskId); + this.setCanCreatePullRequestContext(false); + + try { + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Creating pull request') }, + async () => { + // TODO: The `create-pr` endpoint requires `{owner, repo}` in its path, which we + // derive best-effort from the task's `html_url`. This may need another resolution + // strategy for payloads that omit `html_url` once the backend supports repo ids. + const taskContent = await backend.fetchTaskContent(taskId); + const repo = parseRepoFromTaskUrl(taskContent?.task.html_url); + if (!repo) { + throw new Error(vscode.l10n.t('Unable to determine the repository for this task.')); + } + const result = await backend.createPullRequestForTask(repo.owner, repo.name, taskId); + /* __GDPR__ + "copilotcloud.chat.createPRFromTask" : { + "owner": "joshspicer", + "comment": "Event sent when the user invokes the 'Create pull request' toolbar action on a settled v2 cloud task without a pull request.", + "outcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the create-pull-request call succeeded or failed." } + } + */ + this.telemetry.sendMSFTTelemetryEvent('copilotcloud.chat.createPRFromTask', { + outcome: 'success' + }); + const prNumber = (result as { pull_request?: { number?: number } } | undefined)?.pull_request?.number; + if (typeof prNumber === 'number') { + vscode.window.showInformationMessage(vscode.l10n.t('Pull request #{0} created.', prNumber)); + } else { + vscode.window.showInformationMessage(vscode.l10n.t('Pull request created.')); + } + }, + ); + } catch (error) { + this.logService.error(`[handleCreatePullRequestForTaskCommand] Failed to create PR for task ${taskId}: ${error}`); + this.telemetry.sendMSFTTelemetryEvent('copilotcloud.chat.createPRFromTask', { + outcome: 'failure' + }); + vscode.window.showErrorMessage(vscode.l10n.t('Failed to create pull request: {0}', error instanceof Error ? error.message : String(error))); + // The task is still settled and PR-less, so re-enable the toolbar action for a retry. + this.setCanCreatePullRequestContext(true); + return; + } finally { + this._createPullRequestInFlightTaskIds.delete(taskId); + } + this.refresh(); + } + + /** + * Toggle the {@link CAN_CREATE_PULL_REQUEST_CONTEXT_KEY} context key that gates the + * chat-input "Create pull request" toolbar action. + */ + private setCanCreatePullRequestContext(canCreate: boolean): void { + void vscode.commands.executeCommand('setContext', CAN_CREATE_PULL_REQUEST_CONTEXT_KEY, canCreate); + } + private setWorkspaceContext(key: string, value: string) { this._extensionContext.workspaceState.update(`${this.WORKSPACE_CONTEXT_PREFIX}.${key}`, value); } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/pullArtifactResolver.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/pullArtifactResolver.ts index 5a619d48a88e6..7294caa05e4dd 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/pullArtifactResolver.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/pullArtifactResolver.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { AgentTaskSession } from '@vscode/copilot-api'; import { PullRequestSearchItem } from '../../../platform/github/common/githubAPI'; import { IOctoKitService } from '../../../platform/github/common/githubService'; import { ILogService } from '../../../platform/log/common/logService'; @@ -28,21 +29,30 @@ export async function resolvePullArtifact( octokit: IOctoKitService, log: ILogService, ref: PullArtifactRef, + agentTaskSessions?: AgentTaskSession[], ): Promise { if (ref.preResolved) { return ref.preResolved; } if (ref.globalId) { - try { - const pr = await octokit.getPullRequestFromGlobalId(ref.globalId, {}); - if (pr) { - return pr; + const data = await getPullRequestFromGlobalId(octokit, log, ref.globalId); + if (data) { + return data; + } + } + // Fallback 1: Get global ID from a task session + if (agentTaskSessions && agentTaskSessions.length > 0) { + // TODO: update package with new type + const sessions = agentTaskSessions as (AgentTaskSession & { resource_global_id?: string })[]; + const globalId = sessions.find(s => !!s.resource_global_id)?.resource_global_id; + if (globalId) { + const data = await getPullRequestFromGlobalId(octokit, log, globalId); + if (data) { + return data; } - } catch (e) { - log.trace(`resolvePullArtifact: getPullRequestFromGlobalId failed for ${ref.globalId}: ${e}`); } } - // Fallback to listing the repo's open PRs and matching by databaseId or headRef. We list + // Fallback 2: Listing the repo's open PRs and matching by databaseId or headRef. We list // once and try both predicates so the round trip pays off when either signal is available. if ((ref.databaseId !== undefined || ref.headRef) && ref.repo.owner && ref.repo.name) { try { @@ -66,28 +76,17 @@ export async function resolvePullArtifact( return undefined; } -/** - * Retrying variant. Useful immediately after a task creates a PR — GitHub may take a - * few seconds before the GraphQL node id resolves or the PR appears in the user's open - * list. - */ -export async function resolvePullArtifactWithRetry( +const getPullRequestFromGlobalId = async ( octokit: IOctoKitService, log: ILogService, - ref: PullArtifactRef, - opts: ResolveOptions = {}, -): Promise { - const attempts = opts.attempts ?? 5; - const spacingMs = opts.spacingMs ?? 2_000; - for (let i = 1; i <= attempts; i++) { - const resolved = await resolvePullArtifact(octokit, log, ref); - if (resolved) { - return resolved; - } - if (i < attempts) { - await new Promise(resolve => setTimeout(resolve, spacingMs)); + globalId: string +) => { + try { + const pr = await octokit.getPullRequestFromGlobalId(globalId, {}); + if (pr) { + return pr; } + } catch (e) { + log.trace(`resolvePullArtifact: getPullRequestFromGlobalId failed for ${globalId}: ${e}`); } - log.warn(`resolvePullArtifactWithRetry: could not resolve PR after ${attempts} attempts (globalId=${ref.globalId ?? 'n/a'}, headRef=${ref.headRef ?? 'n/a'}, repo=${ref.repo.owner}/${ref.repo.name})`); - return undefined; -} +}; diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/taskApiBackend.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/taskApiBackend.ts index b414aee3db37d..a7ac8a31bed8c 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/taskApiBackend.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/taskApiBackend.ts @@ -66,8 +66,8 @@ function findPullArtifact(task: AgentTask): (AgentTaskArtifact & { data: AgentTa return task.artifacts?.find( (a): a is AgentTaskArtifact & { data: AgentTaskGitHubResourceData } => a.provider === 'github' - && a.type === 'pull' - && typeof (a.data as AgentTaskGitHubResourceData).id === 'number', + && a.type === 'github_resource' + && (a.data as AgentTaskGitHubResourceData).type === 'pull', ); } @@ -109,9 +109,11 @@ function taskToSessionInfo(task: AgentTask): SessionInfo { * Parse `task.html_url` (e.g. `https://github.com///agents/tasks/`) to * recover the repo identity. The Task API wire shape only carries `task.repository.id`, so * when the caller doesn't already know the repo (e.g. the global `listTasks` path) this is - * how we keep `PullArtifactRef.repo.owner/name` populated for resolver fallbacks. + * how we keep `PullArtifactRef.repo.owner/name` populated for resolver fallbacks. Also + * exported so the provider can derive `{owner, repo}` for the "Create pull request" + * toolbar action on PR-less tasks. */ -function parseRepoFromTaskUrl(htmlUrl: string | undefined): { owner: string; name: string } | undefined { +export function parseRepoFromTaskUrl(htmlUrl: string | undefined): { owner: string; name: string } | undefined { if (!htmlUrl) { return undefined; } @@ -189,7 +191,11 @@ export class TaskApiBackend implements TaskCloudAgentBackend { event_content: params.prompt, problem_statement: params.problemStatement, base_ref: params.baseRef, - create_pull_request: true, + // v2 default: don't auto-create a PR. The provider surfaces a "Create pull + // request" toolbar action in the chat input when the task completes without an + // attached pull artifact, so the user can opt in. See + // `CopilotCloudSessionsProvider.handleCreatePullRequestForTaskCommand`. + create_pull_request: false, event_type: 'visual_studio_code_remote_agent_tool_invoked', ...(params.headRef && { head_ref: params.headRef }), ...(params.customAgent && { custom_agent: params.customAgent }), @@ -333,6 +339,10 @@ export class TaskApiBackend implements TaskCloudAgentBackend { return undefined; } } + + async createPullRequestForTask(owner: string, repo: string, taskId: string): Promise { + return this._taskApiClient.createPRForTask(owner, repo, taskId); + } } /** diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCloudSessionsProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCloudSessionsProvider.spec.ts index e8463bac5aa5c..22e637408e658 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCloudSessionsProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCloudSessionsProvider.spec.ts @@ -5,14 +5,16 @@ import { describe, expect, it, vi } from 'vitest'; import * as vscode from 'vscode'; -import type { AgentTaskGetResponse, AgentTaskSessionEvent } from '@vscode/copilot-api'; +import type { AgentTask, AgentTaskCreateRequest, AgentTaskGetResponse, AgentTaskListEventsResponse, AgentTaskListResponse, AgentTaskSessionEvent, AgentTaskSteerRequest, AgentTaskCreatePullRequestResponse } from '@vscode/copilot-api'; import { IGitService } from '../../../../platform/git/common/gitService'; import { PullRequestSearchItem, SessionInfo } from '../../../../platform/github/common/githubAPI'; import { TestLogService } from '../../../../platform/testing/common/testLogService'; import { mock } from '../../../../util/common/test/simpleMock'; import { ChatRequestTurn2, ChatResponseMarkdownPart, ChatResponseTurn2, ChatToolInvocationPart } from '../../../../vscodeTypes'; +import { ITaskApiClient, ListTaskEventsOptions, ListTasksOptions } from '../../common/taskApiTypes'; import { ChatSessionContentBuilder } from '../copilotCloudSessionContentBuilder'; import { normalizeInitialSessionOptions, parseSessionLogChunksSafely } from '../copilotCloudSessionsProvider'; +import { TaskApiBackend, parseRepoFromTaskUrl } from '../taskApiBackend'; vi.mock('vscode', async () => { const actual = await import('../../../../vscodeTypes'); @@ -315,3 +317,102 @@ describe('ChatSessionContentBuilder Task API history', () => { expect(req.prompt).toBe('Original prompt from creation'); }); }); + +// --- TaskApiBackend (v2) ------------------------------------------------------------------- + +class FakeTaskApiClient implements ITaskApiClient { + public lastCreateRequest: AgentTaskCreateRequest | undefined; + public createPRCalls: Array<{ owner: string; repo: string; taskId: string }> = []; + private readonly _createPRResult: AgentTaskCreatePullRequestResponse; + private readonly _createResult: AgentTask; + + constructor(opts?: { createResult?: AgentTask; createPRResult?: AgentTaskCreatePullRequestResponse }) { + this._createResult = opts?.createResult ?? ({ + id: 'task-created', + state: 'queued', + created_at: '2026-03-27T00:00:00Z', + html_url: 'https://github.com/octocat/hello-world/agents/tasks/task-created', + } as unknown as AgentTask); + this._createPRResult = opts?.createPRResult ?? ({ + pull_request: { number: 42 }, + } as unknown as AgentTaskCreatePullRequestResponse); + } + + async createTask(_owner: string, _repo: string, request: AgentTaskCreateRequest): Promise { + this.lastCreateRequest = request; + return this._createResult; + } + async listTasksForRepo(_owner: string, _repo: string, _options?: ListTasksOptions): Promise { + return { tasks: [] } as unknown as AgentTaskListResponse; + } + async listTasks(_options?: ListTasksOptions): Promise { + return { tasks: [] } as unknown as AgentTaskListResponse; + } + async getTask(_taskId: string): Promise { + return { id: _taskId } as unknown as AgentTaskGetResponse; + } + async getTaskEvents(_taskId: string, _options?: ListTaskEventsOptions): Promise { + return { events: [] } as unknown as AgentTaskListEventsResponse; + } + async steerTask(_taskId: string, _request: AgentTaskSteerRequest): Promise { } + async createPRForTask(owner: string, repo: string, taskId: string): Promise { + this.createPRCalls.push({ owner, repo, taskId }); + return this._createPRResult; + } + async archiveTask(_owner: string, _repo: string, taskId: string): Promise { + return { id: taskId } as unknown as AgentTask; + } + async unarchiveTask(_owner: string, _repo: string, taskId: string): Promise { + return { id: taskId } as unknown as AgentTask; + } +} + +const fakeChatStream = {} as vscode.ChatResponseStream; +const noToken = { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() { } }) } as unknown as vscode.CancellationToken; + +describe('TaskApiBackend', () => { + it('createSession sends create_pull_request: false so the v2 backend no longer auto-creates PRs', async () => { + const client = new FakeTaskApiClient(); + const backend = new TaskApiBackend(client, new TestLogService()); + + await backend.createSession({ + owner: 'octocat', + repo: 'hello-world', + host: 'github.com', + title: 'New task', + prompt: 'Do the thing', + problemStatement: 'Statement', + baseRef: 'main', + }, fakeChatStream, noToken); + + expect(client.lastCreateRequest?.create_pull_request).toBe(false); + }); + + it('createPullRequestForTask delegates to ITaskApiClient.createPRForTask with the same args', async () => { + const client = new FakeTaskApiClient(); + const backend = new TaskApiBackend(client, new TestLogService()); + + const result = await backend.createPullRequestForTask('octocat', 'hello-world', 'task-1'); + + expect(client.createPRCalls).toEqual([{ owner: 'octocat', repo: 'hello-world', taskId: 'task-1' }]); + expect(result).toEqual({ pull_request: { number: 42 } }); + }); +}); + +describe('parseRepoFromTaskUrl', () => { + it('extracts owner and name from a task html_url', () => { + expect(parseRepoFromTaskUrl('https://github.com/octocat/hello-world/agents/tasks/abc')).toEqual({ owner: 'octocat', name: 'hello-world' }); + }); + + it('returns undefined for an unparseable URL', () => { + expect(parseRepoFromTaskUrl('not-a-url')).toBeUndefined(); + }); + + it('returns undefined when the path does not start with owner/repo', () => { + expect(parseRepoFromTaskUrl('https://github.com/')).toBeUndefined(); + }); + + it('returns undefined when the URL is undefined', () => { + expect(parseRepoFromTaskUrl(undefined)).toBeUndefined(); + }); +}); diff --git a/extensions/copilot/src/extension/chatSessions/vscode/cloudAgentBackend.ts b/extensions/copilot/src/extension/chatSessions/vscode/cloudAgentBackend.ts index 7b97c3eabd130..6e9153ef4baa1 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode/cloudAgentBackend.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode/cloudAgentBackend.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { AgentTaskGetResponse, AgentTaskSessionEvent } from '@vscode/copilot-api'; +import { AgentTaskCreatePullRequestResponse, AgentTaskGetResponse, AgentTaskSessionEvent } from '@vscode/copilot-api'; import { GithubRepoId } from '../../../platform/git/common/gitService'; import { PullRequestSearchItem, SessionInfo } from '../../../platform/github/common/githubAPI'; @@ -220,6 +220,18 @@ export interface TaskCloudAgentBackend extends CloudAgentBackendCommon { repo: string, prNumber: number, ): Promise; + + /** + * Materialise a pull request for a task that finished without one. The v2 backend + * no longer auto-creates a PR on `createTask`, so the provider offers a "Create pull + * request" toolbar action in the chat input for a settled, PR-less task that calls this + * method when invoked. + */ + createPullRequestForTask( + owner: string, + repo: string, + taskId: string, + ): Promise; } /** Discriminated union of all backends. Narrow via `backend.kind`. */ diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/copilotPanel/webView/suggestionsPanelWebview.ts b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/copilotPanel/webView/suggestionsPanelWebview.ts index b5ef2656b812e..61ac5acd96c10 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/copilotPanel/webView/suggestionsPanelWebview.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/copilotPanel/webView/suggestionsPanelWebview.ts @@ -4,13 +4,22 @@ *--------------------------------------------------------------------------------------------*/ /// import { provideVSCodeDesignSystem, vsCodeButton } from '@vscode/webview-ui-toolkit'; -import DOMPurify from 'dompurify'; + +interface SanitizableHTMLElement extends HTMLElement { + setHTML(html: string, options: { readonly sanitizer: Sanitizer | SanitizerConfig | SanitizerPresets }): void; +} const solutionsContainer = document.getElementById('solutionsContainer'); const vscode = acquireVsCodeApi(); let currentFocusIndex: number = 0; let solutionEventHandlersInitialized = false; +const snippetSanitizerElements: SanitizerElementWithAttributes[] = [ + { name: 'pre', attributes: ['class', 'style', 'tabindex'] }, + { name: 'code' }, + { name: 'span', attributes: ['class', 'style'] }, +]; + provideVSCodeDesignSystem().register(vsCodeButton()); type Message = { @@ -51,25 +60,81 @@ function handleSolutionUpdate(message: Message) { updateLoadingContainer(message); if (solutionsContainer) { - solutionsContainer.innerHTML = message.solutions - .map((solution, index) => { - const renderedCitation = solution.citation - ? `

- - ${DOMPurify.sanitize(solution.citation.message)} - Inspect source code -

` - : ''; - const sanitizedSnippet = DOMPurify.sanitize(solution.htmlSnippet); - - return `

Suggestion ${index + 1}

-
${sanitizedSnippet - }
- ${DOMPurify.sanitize(renderedCitation)} - Accept suggestion ${index + 1 - }`; - }) - .join(''); + solutionsContainer.replaceChildren(...message.solutions.flatMap((solution, index) => createSolutionElements(solution, index))); + } +} + +function createSolutionElements(solution: Message['solutions'][number], index: number): HTMLElement[] { + const solutionNumber = index + 1; + const heading = document.createElement('h3'); + heading.className = 'solutionHeading'; + heading.id = `solution-${solutionNumber}-heading`; + heading.textContent = `Suggestion ${solutionNumber}`; + + const snippetContainer = document.createElement('div'); + snippetContainer.className = 'snippetContainer'; + snippetContainer.setAttribute('aria-labelledby', heading.id); + snippetContainer.setAttribute('role', 'group'); + snippetContainer.dataset.solutionIndex = String(index); + setSnippetHtml(snippetContainer, solution.htmlSnippet); + + const acceptButton = document.createElement('vscode-button'); + acceptButton.setAttribute('role', 'button'); + acceptButton.className = 'acceptButton'; + acceptButton.id = `acceptButton${index}`; + acceptButton.setAttribute('appearance', 'secondary'); + acceptButton.dataset.solutionIndex = String(index); + acceptButton.textContent = `Accept suggestion ${solutionNumber}`; + + const elements: HTMLElement[] = [heading, snippetContainer]; + const citation = solution.citation ? createCitationElement(solution.citation) : undefined; + if (citation) { + elements.push(citation); + } + elements.push(acceptButton); + return elements; +} + +function setSnippetHtml(element: HTMLElement, html: string): void { + const sanitizerElement = element as unknown as SanitizableHTMLElement; + sanitizerElement.setHTML(html, { sanitizer: getSnippetSanitizer() }); +} + +let snippetSanitizer: Sanitizer | undefined; +function getSnippetSanitizer(): Sanitizer { + return snippetSanitizer ??= new Sanitizer({ + elements: snippetSanitizerElements, + }); +} + +function createCitationElement(citation: NonNullable): HTMLElement { + const paragraph = document.createElement('p'); + + const warning = document.createElement('span'); + warning.style.verticalAlign = 'text-bottom'; + warning.setAttribute('aria-hidden', 'true'); + warning.textContent = 'Warning'; + paragraph.append(warning, ' ', citation.message, ' '); + + const trustedUrl = getTrustedCitationUrl(citation.url); + if (trustedUrl) { + const link = document.createElement('a'); + link.href = trustedUrl; + link.target = '_blank'; + link.rel = 'noreferrer noopener'; + link.textContent = 'Inspect source code'; + paragraph.append(link); + } + + return paragraph; +} + +function getTrustedCitationUrl(url: string): string | undefined { + try { + const parsedUrl = new URL(url); + return parsedUrl.protocol === 'https:' ? parsedUrl.href : undefined; + } catch { + return undefined; } } diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/highlighter.ts b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/highlighter.ts index 1d658ecf516ac..72cfe8fe130b4 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/highlighter.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/panelShared/highlighter.ts @@ -40,7 +40,7 @@ export class Highlighter { createSnippet(text: string): string { if (!this.highlighter || !this.languageId || !this.languageSupported()) { - return `
${text}
`; + return `
${escapeHtml(text)}
`; } return this.highlighter.codeToHtml(text, { lang: this.languageId, theme: getCurrentTheme() }); @@ -57,6 +57,19 @@ export class Highlighter { } } +function escapeHtml(text: string): string { + return text.replace(/[&<>"]/g, char => { + switch (char) { + case '&': return '&'; + case '<': return '<'; + case '>': return '>'; + case '"': return '"'; + } + + return char; + }); +} + function getCurrentTheme(): ThemeRegistration { const workbenchConfig = workspace.getConfiguration('workbench'); if (workbenchConfig) { diff --git a/extensions/copilot/src/extension/prompt/vscode-node/endpointProviderImpl.ts b/extensions/copilot/src/extension/prompt/vscode-node/endpointProviderImpl.ts index e3182bc8e6de3..c31b8dfc034c3 100644 --- a/extensions/copilot/src/extension/prompt/vscode-node/endpointProviderImpl.ts +++ b/extensions/copilot/src/extension/prompt/vscode-node/endpointProviderImpl.ts @@ -121,6 +121,14 @@ export class ProductionEndpointProvider extends Disposable implements IEndpointP } } + // Utility-family aliases (published by LanguageModelAccess under the copilot vendor) + // have synthetic ids that don't map to any real CAPI model, so the lookup below + // would silently fall back to `copilot-utility`. Route them through the family + // resolver so the chat-participant path matches direct `getChatEndpoint(family)` callers. + if (model.id === 'copilot-utility-small' || model.id === 'copilot-utility') { + return this.getChatEndpoint(model.id); + } + const modelMetadata = await this._modelFetcher.getChatModelFromApiModel(model); // If we fail to resolve a model since this is panel we give copilot utility. This really should never happen as the picker is powered by the same service. return modelMetadata ? this.getOrCreateChatEndpointInstance(modelMetadata) : this.getChatEndpoint('copilot-utility'); diff --git a/extensions/npm/package.json b/extensions/npm/package.json index 57c27aec70553..fd5f0c62bab3d 100644 --- a/extensions/npm/package.json +++ b/extensions/npm/package.json @@ -266,7 +266,8 @@ "yarn", "pnpm", "bun", - "node" + "node", + "vp" ], "enumDescriptions": [ "%config.npm.scriptRunner.auto%", @@ -274,7 +275,8 @@ "%config.npm.scriptRunner.yarn%", "%config.npm.scriptRunner.pnpm%", "%config.npm.scriptRunner.bun%", - "%config.npm.scriptRunner.node%" + "%config.npm.scriptRunner.node%", + "%config.npm.scriptRunner.vp%" ], "default": "auto", "description": "%config.npm.scriptRunner%" diff --git a/extensions/npm/package.nls.json b/extensions/npm/package.nls.json index 1235a5519c1e7..5c77708d9427f 100644 --- a/extensions/npm/package.nls.json +++ b/extensions/npm/package.nls.json @@ -17,6 +17,7 @@ "config.npm.scriptRunner.pnpm": "Use pnpm as the script runner.", "config.npm.scriptRunner.bun": "Use bun as the script runner.", "config.npm.scriptRunner.node": "Use Node.js as the script runner.", + "config.npm.scriptRunner.vp": "Use Vite+ (vp) as the script runner.", "config.npm.scriptRunner.auto": "Auto-detect which script runner to use based on lock files and installed package managers.", "config.npm.exclude": "Configure glob patterns for folders that should be excluded from automatic script detection.", "config.npm.enableScriptExplorer": "Enable an explorer view for npm scripts when there is no top-level `package.json` file.", diff --git a/src/vs/editor/common/tokens/sparseTokensStore.ts b/src/vs/editor/common/tokens/sparseTokensStore.ts index 02f803292213d..a7cf12624daf5 100644 --- a/src/vs/editor/common/tokens/sparseTokensStore.ts +++ b/src/vs/editor/common/tokens/sparseTokensStore.ts @@ -158,7 +158,7 @@ export class SparseTokensStore { let lastEndOffset = 0; const emitToken = (endOffset: number, metadata: number) => { - if (endOffset === lastEndOffset) { + if (endOffset <= lastEndOffset) { return; } lastEndOffset = endOffset; diff --git a/src/vs/editor/test/common/model/tokensStore.test.ts b/src/vs/editor/test/common/model/tokensStore.test.ts index 98716e096d28b..2a8d5f027e9b4 100644 --- a/src/vs/editor/test/common/model/tokensStore.test.ts +++ b/src/vs/editor/test/common/model/tokensStore.test.ts @@ -547,6 +547,54 @@ suite('TokensStore', () => { assert.strictEqual(lineTokens.getEndOffset(1), 10, 'Semantic token should end at offset 10'); }); + test('addSparseTokens skips overlapping semantic tokens that produce backward endOffsets', () => { + // This test reproduces a rendering glitch where characters are duplicated in the DOM. + // When typing at a semantic token boundary, `acceptInsertText` can expand a token + // and create overlapping ranges (e.g., token '+' at (3,5) and token '2' at (4,5)). + // The merge in `addSparseTokens` must not produce backward endOffset sequences, + // otherwise `LineTokens.withInserted` re-copies characters causing duplication. + const codec = new LanguageIdCodec(); + const store = new SparseTokensStore(codec); + + // Simulate overlapping semantic tokens after an edit: + // Original: f=1+2 with tokens at (0,1), (1,2), (2,3), (3,4), (4,5) + // After inserting 'a' at offset 4: token (3,4) expands to (3,5), token (4,5) stays + // This creates overlap: (3,5) and (4,5) + const semanticMeta1 = (1 << MetadataConsts.FOREGROUND_OFFSET) | MetadataConsts.SEMANTIC_USE_FOREGROUND; + const semanticMeta2 = (2 << MetadataConsts.FOREGROUND_OFFSET) | MetadataConsts.SEMANTIC_USE_FOREGROUND; + store.set([ + SparseMultilineTokens.create(1, new Uint32Array([ + // deltaLine, startChar, endChar, metadata + 0, 0, 1, semanticMeta1, // 'f' at (0,1) + 0, 1, 2, semanticMeta2, // '=' at (1,2) + 0, 2, 3, semanticMeta1, // '1' at (2,3) + 0, 3, 5, semanticMeta2, // '+a' at (3,5) - expanded after edit + 0, 4, 5, semanticMeta1, // overlapping: 'a' at (4,5) - stale position + ])) + ], true); + + const tmMeta = (3 << MetadataConsts.FOREGROUND_OFFSET) >>> 0; + const lineTokens = store.addSparseTokens(1, new LineTokens(new Uint32Array([ + 6, tmMeta, // entire line "f=1+a2" covered by one TM token + ]), `f=1+a2`, codec)); + + // Verify endOffsets are monotonically increasing (no backward sequences) + const endOffsets: number[] = []; + for (let i = 0; i < lineTokens.getCount(); i++) { + endOffsets.push(lineTokens.getEndOffset(i)); + } + for (let i = 1; i < endOffsets.length; i++) { + assert.ok(endOffsets[i] > endOffsets[i - 1], + `endOffset[${i}]=${endOffsets[i]} should be > endOffset[${i - 1}]=${endOffsets[i - 1]}`); + } + + // When used with injected text, the resulting LineTokens must not duplicate characters. + // Simulate injected text " " at offset 0 (like the repro's `before: { content: " " }`) + const withInjected = lineTokens.withInserted([{ offset: 0, text: ' ', tokenMetadata: LineTokens.defaultTokenMetadata }]); + assert.strictEqual(withInjected.getLineContent(), ' f=1+a2', + 'withInserted must not duplicate characters when semantic tokens overlap'); + }); + test('piece with startLineNumber 0 and endLineNumber -1 after encompassing deletion', () => { const codec = new LanguageIdCodec(); const store = new SparseTokensStore(codec); diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index 467c4d3faf650..9c44b4a013fc0 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -661,9 +661,13 @@ export function summarizeStderrForError(stderr: string): string { if (lines.length === 0) { return ''; } - const last = lines[lines.length - 1]; const MAX = 200; - return last.length > MAX ? `${last.slice(0, MAX - 1)}…` : last; + const gitLfsMissing = lines.find(line => + /\bgit-lfs\b/i.test(line) && + /(command not found|not recognized|no such file)/i.test(line) + ); + const summary = gitLfsMissing ?? lines[lines.length - 1]; + return summary.length > MAX ? `${summary.slice(0, MAX - 1)}…` : summary; } /** diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts index 27714293d8cb9..5ada583a94ebd 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts @@ -296,6 +296,19 @@ suite('AgentHostGitService', () => { ); }); + test('keeps missing git-lfs error over the later generic fatal line', () => { + const err = Object.assign(new Error('Command failed'), { code: 128 }); + const stderr = [ + 'Preparing worktree (new branch \'agents/example\')', + 'git-lfs filter-process: git-lfs: command not found', + 'fatal: the remote end hung up unexpectedly', + ].join('\n'); + assert.strictEqual( + formatGitError(['worktree', 'add', '--no-track', '-b', 'agents/example', '/tmp/worktree', 'origin/main'], 60_000, false, err, stderr), + 'git worktree exited with code 128: git-lfs filter-process: git-lfs: command not found', + ); + }); + test('falls back to error message when there is no signal or exit code', () => { const err = new Error('spawn git ENOENT'); assert.strictEqual( diff --git a/src/vs/platform/sandbox/common/terminalSandboxEngine.ts b/src/vs/platform/sandbox/common/terminalSandboxEngine.ts index 21758df2ca8b0..fb56c1a2856ae 100644 --- a/src/vs/platform/sandbox/common/terminalSandboxEngine.ts +++ b/src/vs/platform/sandbox/common/terminalSandboxEngine.ts @@ -747,21 +747,23 @@ export class TerminalSandboxEngine extends Disposable { private async _resolveFileSystemPaths(paths: string[] | undefined): Promise { const resolvedPaths = await Promise.all((paths ?? []).map(path => this._resolveFileSystemPath(path))); - return [...new Set(resolvedPaths)]; + return [...new Set(resolvedPaths.flat())]; } - private async _resolveFileSystemPath(path: string): Promise { + private async _resolveFileSystemPath(path: string): Promise { const expandedPath = this._os === OperatingSystem.Linux ? this._expandHomePath(path) : path; if (!this._isAbsoluteFileSystemPath(expandedPath)) { - return expandedPath; + return [expandedPath]; } try { const realpath = await this._fileService.realpath(this._toFileSystemResource(expandedPath)); const resolvedPath = realpath ? this._getUriPath(realpath) : undefined; - return resolvedPath && resolvedPath !== expandedPath ? resolvedPath : expandedPath; + // Keep the expanded path (the configured path after home expansion) so permissions apply when accessed through the symlink. + // Also include the resolved path (the canonical symlink target) so the same permissions apply when accessed directly. + return resolvedPath && resolvedPath !== expandedPath ? [expandedPath, resolvedPath] : [expandedPath]; } catch { - return expandedPath; + return [expandedPath]; } } diff --git a/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts b/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts index a6bfeebe2ba0a..20b2f347d78ab 100644 --- a/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts +++ b/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts @@ -293,7 +293,7 @@ suite('TerminalSandboxEngine', () => { ok(!config.filesystem.allowWrite.includes('/workspace-a'), 'Refreshed config should drop the old write root'); }); - test('resolves filesystem paths and expands home on Linux when writing the config', async () => { + test('preserves filesystem symlink paths and resolves their targets on Linux when writing the config', async () => { setSandboxSetting(AgentSandboxSettingId.AgentSandboxLinuxFileSystem, { allowRead: ['~/read-link'], allowWrite: ['/write-link'], @@ -316,13 +316,20 @@ suite('TerminalSandboxEngine', () => { const configPath = await engine.getSandboxConfigPath(); ok(configPath, 'Config path should be defined'); const config = JSON.parse(createdFiles.get(configPath)!); - ok(config.filesystem.allowWrite.includes('/real/workspace'), 'Workspace write root symlink should be resolved'); - ok(config.filesystem.allowWrite.includes('/real/write'), 'Configured allowWrite symlink should be resolved'); - ok(config.filesystem.allowRead.includes('/real/read'), 'Configured allowRead should expand ~ and resolve symlink'); - ok(config.filesystem.allowRead.includes('/real/gnupg'), 'Command runtime allowRead should expand ~ and resolve symlink'); - ok(config.filesystem.allowWrite.includes('/real/gnupg'), 'Command runtime allowWrite should expand ~ and resolve symlink'); - ok(config.filesystem.denyRead.includes('/real/deny-read'), 'Configured denyRead should expand ~ and resolve symlink'); - ok(config.filesystem.denyWrite.includes('/real/deny-write'), 'Configured denyWrite symlink should be resolved'); + ok(config.filesystem.allowWrite.includes('/workspace-link'), 'Workspace write root symlink should be preserved'); + ok(config.filesystem.allowWrite.includes('/real/workspace'), 'Workspace write root symlink target should be included'); + ok(config.filesystem.allowWrite.includes('/write-link'), 'Configured allowWrite symlink should be preserved'); + ok(config.filesystem.allowWrite.includes('/real/write'), 'Configured allowWrite symlink target should be included'); + ok(config.filesystem.allowRead.includes('/home/user/read-link'), 'Configured allowRead should expand ~ and preserve the symlink'); + ok(config.filesystem.allowRead.includes('/real/read'), 'Configured allowRead symlink target should be included'); + ok(config.filesystem.allowRead.includes('/home/user/.gnupg'), 'Command runtime allowRead symlink should be preserved'); + ok(config.filesystem.allowRead.includes('/real/gnupg'), 'Command runtime allowRead symlink target should be included'); + ok(config.filesystem.allowWrite.includes('/home/user/.gnupg'), 'Command runtime allowWrite symlink should be preserved'); + ok(config.filesystem.allowWrite.includes('/real/gnupg'), 'Command runtime allowWrite symlink target should be included'); + ok(config.filesystem.denyRead.includes('/home/user/deny-read-link'), 'Configured denyRead should expand ~ and preserve the symlink'); + ok(config.filesystem.denyRead.includes('/real/deny-read'), 'Configured denyRead symlink target should be included'); + ok(config.filesystem.denyWrite.includes('/deny-write-link'), 'Configured denyWrite symlink should be preserved'); + ok(config.filesystem.denyWrite.includes('/real/deny-write'), 'Configured denyWrite symlink target should be included'); }); test('keeps filesystem paths without symlinks when writing the config', async () => { @@ -473,7 +480,7 @@ suite('TerminalSandboxEngine', () => { strictEqual(config.version, '0.5.0-alpha'); }); - test('resolves Windows filesystem symlinks when writing MXC config', async () => { + test('preserves Windows filesystem symlink paths and resolves their targets when writing MXC config', async () => { enableWindowsSandbox(); setSandboxSetting(AgentSandboxSettingId.AgentSandboxWindowsFileSystem, { allowWrite: ['C:\\configured\\write-link'], @@ -495,11 +502,16 @@ suite('TerminalSandboxEngine', () => { ok(configPath, 'Config path should be defined'); const config = JSON.parse(createdFiles.get(configPath)!); - ok(config.filesystem.readwritePaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/real/workspace'), 'Workspace write root symlink should be resolved on Windows'); - ok(config.filesystem.readwritePaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/real/configured-write'), 'Configured Windows allowWrite symlink should be resolved'); - ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/real/configured-read'), 'Configured Windows allowRead symlink should be resolved'); - ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/real/tools-node'), 'Windows policy readonly symlink should be resolved'); - ok(config.filesystem.deniedPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/real/configured-secret'), 'Configured Windows denyRead symlink should be resolved'); + ok(config.filesystem.readwritePaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/workspace-link'), 'Workspace write root symlink should be preserved on Windows'); + ok(config.filesystem.readwritePaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/real/workspace'), 'Workspace write root symlink target should be included on Windows'); + ok(config.filesystem.readwritePaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/configured/write-link'), 'Configured Windows allowWrite symlink should be preserved'); + ok(config.filesystem.readwritePaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/real/configured-write'), 'Configured Windows allowWrite symlink target should be included'); + ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/configured/read-link'), 'Configured Windows allowRead symlink should be preserved'); + ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/real/configured-read'), 'Configured Windows allowRead symlink target should be included'); + ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/tools/node'), 'Windows policy readonly symlink should be preserved'); + ok(config.filesystem.readonlyPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/real/tools-node'), 'Windows policy readonly symlink target should be included'); + ok(config.filesystem.deniedPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/configured/secret-link'), 'Configured Windows denyRead symlink should be preserved'); + ok(config.filesystem.deniedPaths.some((path: string) => normalizeWindowsPathForAssert(path) === 'c:/real/configured-secret'), 'Configured Windows denyRead symlink target should be included'); }); test('wrapCommand uses arm64 MXC executable on Windows arm64', async () => { diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index b90724e756bdd..4751422e82c1b 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -40,14 +40,44 @@ Defines the foundational interfaces that all providers and consumers share: - **`ISession`** (`session.ts`) — Universal session facade. A self-contained observable object representing a session; consumers never reach back to provider internals. Each session has a globally unique ID built via `toSessionId(providerId, resource)` and groups one or more `IChat` instances. - **`ISessionsProvider`** (`sessionsProvider.ts`) — Contract every provider implements. Covers workspace discovery, session CRUD, sending requests, model enumeration/selection/presentation (`getModels`, `getModelPickerOptions`, `onDidChangeModels`, `setModel`), and firing change events. -- **`ISessionsManagementService`** (`sessionsManagement.ts`) — High-level orchestration interface consumed by UI. Aggregates sessions from all providers, tracks the active session, manages navigation history, and updates context keys. +- **`ISessionsManagementService`** (`sessionsManagement.ts`) — The session **model** service. Aggregates sessions from all providers, owns the canonical `activeSession` (+ `setActiveSession`, called by the view), the pending new-session draft (`createNewSession`/`isNewChatSession`), send (`sendNewChatRequest`/`createAndSendNewChatRequest`/`sendRequest`), CRUD (archive/delete/rename), recency history, and the active-session context keys. It performs **no** view/layout mutation and never imports the core view or part. + +> **Model vs view.** Opening sessions, the visible-session slots and their arrangement, focus, Back/Forward navigation, and per-session view persistence live in **`ISessionsViewService`** (core — see `browser/sessionsViewService.ts`), not the management service. The split mirrors `IEditorService.activeEditor` (model) vs `IEditorGroupsService.activeGroup` + focus (view). See [Model vs View](#model-vs-view-session-services). ### Layer 2 — Sessions Services (`services/sessions/browser/`) Concrete implementations of the core interfaces: - **`SessionsProvidersService`** — A pure registry. Providers register here; it fires `onDidChangeProviders` and provides lookup by ID. It does **not** aggregate sessions or route actions. -- **`SessionsManagementService`** — Wraps the providers service with UI concerns: active session tracking, back/forward navigation, and context key management. +- **`SessionsManagementService`** — The model implementation: aggregates provider sessions, owns `activeSession`/`setActiveSession`, the pending draft, send, CRUD, recency history, and active-session context keys. Reduced send methods to provider calls + `onWillSendRequest`/`onDidStartSession`/`onDidSendRequest` events; the view reacts to those (and `onDidReplaceSession`) to keep the visible slot in sync. It performs no visible-session/layout mutation. + +The **view** counterpart, **`SessionsViewService`** (core, `browser/sessionsViewService.ts`), owns the `VisibleSessions` model (slots/arrangement), opening (`openSession`/`openChat`/`openNewSession`/`openNewChatInSession`), `insertAt`, stickiness, `close*`, focus (drives the passive part and honours `openSession(..., { preserveFocus })`), `SessionsNavigation` (Back/Forward), and `restoreVisibleSessions` + per-session view persistence. Because it is **core**, it may import both the part (core) and the management service (services). It pushes the active slot into the model via `management.setActiveSession(...)`. + +#### Model vs View (session services) + +| `ISessionsManagementService` (model — `services/sessions`) | `ISessionsViewService` (view — core `browser/`) | +|---|---| +| canonical `activeSession` + `setActiveSession(session)` (called by the view) | `visibleSessions` (slots/arrangement) + active-slot wrappers | +| active-session context keys; `isNewChatSession` (new-draft ctx key) | `openSession`/`openChat`/`openNewSession`/`openNewChatInSession` | +| providers, getters, recently-opened, session types, `resolveWorkspace` | `insertAt`, `toggleSessionStickiness`, `closeSession`/`closeAllSessions`, `setActive` | +| `createNewSession` + new-session draft (`newSession` observable, `discardNewSession`) | focus mechanics (drives the part); `preserveFocus` | +| `sendNewChatRequest`/`createAndSendNewChatRequest`/`sendRequest` (provider calls + send events) | Back/Forward navigation (`SessionsNavigation`) | +| CRUD: archive/delete/rename + events; recency history; provider subscriptions | `restoreVisibleSessions` + per-session view persistence; reflects send/replace **reactively** | + +**Data-flow contract:** + +``` +open existing: view.openSession(uri, { preserveFocus }) + → management.setActiveSession(session) // model truth (core → services) + → view arranges visible slot + focuses // focus skipped when preserveFocus +new session: composer → view.openNewSession({ folderUri, ... }) // view: management.createNewSession() (model draft) + activates it + → view observes activeSession == draft → shows draft slot +send: composer → management.sendNewChatRequest() // model: provider calls + events + → view reacts (onDidReplaceSession + active-session chats) → swaps slot / active chat +focus a slot: part.onDidFocusSession → view.setActive → management.setActiveSession +``` + +The part (`browser/parts/sessionsPartService.ts`) is a **passive renderer**: it injects neither the model nor the view, and only exposes `updateVisibleSessions(visible, active)`, `focusSession`, and `onDidFocusSession`. The view owns the reconcile autorun and focus and wires `part.onDidFocusSession → view.setActive`. ### Layer 3 — Providers (`contrib/providers/`) @@ -115,7 +145,7 @@ Session types are surfaced ordered by each provider's `order` property (lower fi The session type picker persists the last selection as `{ providerId, sessionTypeId }` (the `providerId` disambiguates when two providers offer the same `sessionType.id`, e.g. `copilotcli`). Like any picker, it writes storage whenever the value changes — both on a manual dropdown pick and whenever the active session's type changes — so an auto-selected or defaulted type also survives reload (otherwise the stored preference would be empty and the restored draft would fall back to the first provider by `order`). -On reload, providers register asynchronously and agent hosts connect lazily, so the preferred provider may not have surfaced its session types when the restored draft is created. The workspace picker fires `onDidSelectWorkspace` as soon as the workspace is recognized; `NewChatWidget` must **not** pre-validate the stored pick against the folder's currently-available types at that moment (doing so discards a still-valid preference whose provider just hasn't registered yet, locking in the wrong default). Instead `NewChatWidget._createNewSession` creates the draft immediately with the best available provider, then upgrades it in place once the preferred `(providerId, sessionTypeId)` pair becomes servable (driven by `onDidChangeSessionTypes`). The upgrade listener lives for the widget's lifetime — there is **no** timeout or `LifecyclePhase` give-up, since an agent host can connect arbitrarily late — and is cancelled if the user picks a different type/workspace or the draft is sent. +On reload, providers register asynchronously and agent hosts connect lazily, so the preferred provider may not have surfaced its session types when the restored draft is created. Rather than blocking on a "ready" gate, `NewChatWidget` creates the draft immediately with the best available provider, then upgrades it in place once the preferred `(providerId, sessionTypeId)` pair becomes servable (driven by `onDidChangeSessionTypes`). The upgrade listener lives for the widget's lifetime — there is **no** timeout or `LifecyclePhase` give-up, since an agent host can connect arbitrarily late — and is cancelled if the user picks a different type or the draft is sent. ### Changesets @@ -130,17 +160,19 @@ Sessions produce file changes organized into **`ISessionChangeset`** groups — ``` 1. User picks a folder in the workspace picker → WorkspacePicker fires onDidSelectWorkspace(folderUri) - → SessionsManagementService.createNewSession(folderUri, options?) + → NewChatWidget → ISessionsViewService.openNewSession({ folderUri, ...options }) + → view calls SessionsManagementService.createNewSession(folderUri, options?) → Iterates providers, picks the first one whose resolveWorkspace(folderUri) succeeds (filtered by options.sessionTypeId when given) → Calls provider.createNewSession(folderUri, sessionTypeId) - → Returns ISession, set as activeSession + → Returns ISession (model draft, `newSession`); the view then activates it so + it becomes the activeSession and the draft slot shows reactively 2. User picks a different session type for the same folder → SessionTypePicker queries getSessionTypesForFolder(folderUri), groups entries by provider, shows them in the dropdown → On selection, fires onDidSelectSessionType({ providerId, sessionTypeId }) - → SessionsManagementService.createNewSession(folderUri, { providerId, sessionTypeId }) + → NewChatWidget → ISessionsViewService.openNewSession({ folderUri, providerId, sessionTypeId }) routes through the picked provider — even when the same sessionType.id is also offered by another provider @@ -148,35 +180,31 @@ Sessions produce file changes organized into **`ISessionChangeset`** groups — → SessionsManagementService.sendNewChatRequest(session, {query, attachedContext}) → Calls provider.createNewChat(sessionId) → Provider creates the backend chat model and returns an IChat - → Management service opens the chat widget with that chat's resource + → Management fires onWillSendRequest(session); the view follows the send to + keep the newest chat active in the visible slot → ChatView locks the embedded ChatWidget to the contributed chat session type (for example agent-host-codex) before setting the model, so follow-up turns keep routing to the provider that owns the session; local chat sessions unlock → Delegates to provider.sendRequest(sessionId, chatResource, options) → Provider sends request, returns committed session - → Management service fires onDidStartSession(committedSession) + → Management fires onDidStartSession(committedSession) + onDidSendRequest(...) → isNewChatSession context → false ``` Follow-up messages to an existing chat go through -`SessionsManagementService.sendRequest(session, chat, options)`. This always -makes the sent chat the active chat. +`SessionsManagementService.sendRequest(session, chat, options)`. The view makes +the sent chat the active chat by reacting to the send events. Explicit user-initiated "new session" gestures (Ctrl/Cmd+N, the **New** button, the mobile titlebar "+" button, and the sessions quick picker's "New Session" -item) call `openNewSessionView({ inheritWorkspaceFromActiveSession: true })`. -When a session is already active, the new session view inherits that session's -workspace — a fresh pending new session is created via `createNewSession` for -the active session's workspace folder — instead of defaulting to the workspace -of the last composed new session. Inheritance is skipped when the active -session's workspace already matches the current pending new session (so an -in-progress draft for that same workspace is preserved) and falls back to the -default behavior if the workspace cannot be resolved. Internal callers (restore -fallback, archive, background reseed, and the close-session fallback) invoke -`openNewSessionView()` without the flag and keep the prior behavior. +item) call `ISessionsViewService.openNewSession()`. With no `folderUri` this +switches to the new-session view, restoring the in-progress draft (`newSession`) +when one exists or showing the empty placeholder otherwise. Internal callers +(restore fallback, archive, background reseed, and the close-session fallback) +invoke `openNewSession()` the same way. `sendNewChatRequest(session, options)` accepts a `background` flag: a background new-session send returns the agents window to a fresh new-session view (via -`openNewSessionView`) **before** creating and sending the session, and skips the +`openNewSession`) **before** creating and sending the session, and skips the visible-slot swap (`updateResourceOfSession`/`updateSession`) that the foreground path uses. This keeps the composer in view the whole time — the started session is never momentarily shown in the chat view — and it just appears in the sessions diff --git a/src/vs/sessions/browser/parts/chatCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts index 4c4fa04393f2f..6ecbac09ac8b4 100644 --- a/src/vs/sessions/browser/parts/chatCompositeBar.ts +++ b/src/vs/sessions/browser/parts/chatCompositeBar.ts @@ -21,6 +21,7 @@ import { localize } from '../../../nls.js'; import { IQuickInputService } from '../../../platform/quickinput/common/quickInput.js'; import { IChat, SessionStatus } from '../../services/sessions/common/session.js'; import { IActiveSession, ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js'; +import { ISessionsViewService } from '../sessionsViewService.js'; import { IHoverService } from '../../../platform/hover/browser/hover.js'; import { getDefaultHoverDelegate } from '../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { applySessionBarThemeColors } from './sessionBarStyles.js'; @@ -74,6 +75,7 @@ export class ChatCompositeBar extends Disposable { constructor( @IThemeService private readonly _themeService: IThemeService, @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @ISessionsViewService private readonly _sessionsViewService: ISessionsViewService, @IContextMenuService private readonly _contextMenuService: IContextMenuService, @IQuickInputService private readonly _quickInputService: IQuickInputService, @IHoverService private readonly _hoverService: IHoverService, @@ -301,7 +303,7 @@ export class ChatCompositeBar extends Disposable { private _onTabClicked(chat: IChat): void { if (this._session) { - this._sessionsManagementService.openChat(this._session, chat.resource); + this._sessionsViewService.openChat(this._session, chat.resource); } } diff --git a/src/vs/sessions/browser/parts/sessionDropTarget.ts b/src/vs/sessions/browser/parts/sessionDropTarget.ts index f07366f6f28e1..e08a4f8936895 100644 --- a/src/vs/sessions/browser/parts/sessionDropTarget.ts +++ b/src/vs/sessions/browser/parts/sessionDropTarget.ts @@ -15,6 +15,7 @@ import { IThemeService, Themable } from '../../../platform/theme/common/themeSer import { EDITOR_DRAG_AND_DROP_BACKGROUND } from '../../../workbench/common/theme.js'; import { DraggedSessionIdentifier } from '../dnd.js'; import { ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js'; +import { ISessionsViewService } from '../sessionsViewService.js'; /** Side of a target view where a dragged session can be dropped. */ type DropSide = 'left' | 'right'; @@ -48,6 +49,7 @@ class SessionDropOverlay extends Themable { private readonly _targetElement: HTMLElement, @IThemeService themeService: IThemeService, @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @ISessionsViewService private readonly _sessionsViewService: ISessionsViewService, ) { super(themeService); @@ -143,7 +145,7 @@ class SessionDropOverlay extends Themable { return; } - this._sessionsManagementService.insertAt(session, this.targetSessionId, side); + this._sessionsViewService.insertAt(session, this.targetSessionId, side); } private _positionOverlay(mousePosX: number): void { diff --git a/src/vs/sessions/browser/parts/sessionsPartService.ts b/src/vs/sessions/browser/parts/sessionsPartService.ts index 89c0c82ec6124..ba42d46be1aab 100644 --- a/src/vs/sessions/browser/parts/sessionsPartService.ts +++ b/src/vs/sessions/browser/parts/sessionsPartService.ts @@ -3,18 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable } from '../../../base/common/lifecycle.js'; -import { createDecorator, IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; -import { InstantiationType, registerSingleton } from '../../../platform/instantiation/common/extensions.js'; -import { getClientArea } from '../../../base/browser/dom.js'; -import { mainWindow } from '../../../base/browser/window.js'; -import { SessionsPart } from './sessionsPart.js'; -import { MobileSessionsPart } from './mobile/mobileSessionsPart.js'; -import { SessionView } from './sessionView.js'; -import { IActiveSession, ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js'; -import { autorun } from '../../../base/common/observable.js'; +import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; +import type { SessionView } from './sessionView.js'; +import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; import { IProgressIndicator } from '../../../platform/progress/common/progress.js'; -import { Emitter, Event } from '../../../base/common/event.js'; +import { Event } from '../../../base/common/event.js'; export const ISessionsPartService = createDecorator('sessionsPartService'); @@ -30,6 +23,21 @@ export interface IToggleMaximizeSessionEvent { export interface ISessionsPartService { readonly _serviceBrand: undefined; + /** + * Reconciles the part's grid so it renders exactly the given visible + * sessions (and active session). Called by the view service whenever the + * visible sessions or active session change. The part is a passive renderer: + * it does not observe the model itself. + */ + updateVisibleSessions(visible: readonly (IActiveSession | undefined)[], active: IActiveSession | undefined): void; + + /** + * Fires with the session id of a grid slot that received keyboard focus. The + * view service listens to promote that session to the active session. Only + * fires for non-placeholder slots. + */ + readonly onDidFocusSession: Event; + /** * Toggles the maximized state of the session view hosting the given session * in the sessions part's grid. @@ -63,92 +71,3 @@ export interface ISessionsPartService { */ getProgressIndicator(): IProgressIndicator; } - -/** - * Owns the lifecycle of the {@link SessionsPart}. Selects the mobile vs. desktop - * variant based on viewport width at construction time. Registered as an eager - * singleton so the part registers itself with the workbench layout service - * before the workbench starts laying out parts. - */ -export class SessionsParts extends Disposable implements ISessionsPartService { - - declare readonly _serviceBrand: undefined; - - private readonly _mainPart: SessionsPart; - - private readonly _onDidToggleMaximizeSession = this._register(new Emitter()); - readonly onDidToggleMaximizeSession: Event = this._onDidToggleMaximizeSession.event; - - /** - * Session id (or `undefined` for the new-session slot) that focus was last - * moved into in response to an active-session change. Tracks the active id - * so unrelated visibility updates don't re-focus and steal focus. - */ - private _focusedActiveSessionId: string | undefined; - - constructor( - @IInstantiationService instantiationService: IInstantiationService, - @ISessionsManagementService sessionsManagementService: ISessionsManagementService - ) { - super(); - - const { width } = getClientArea(mainWindow.document.body); - const isPhoneLayout = width < 640; - - this._mainPart = this._register(instantiationService.createInstance(isPhoneLayout ? MobileSessionsPart : SessionsPart)); - - this._register(autorun(reader => { - const visible = sessionsManagementService.visibleSessions.read(reader); - const active = sessionsManagementService.activeSession.read(reader); - this._mainPart.updateVisibleSessions(visible, active); - - // Move keyboard focus into the active session whenever it changes - // (e.g. after opening, switching to, or restoring a session) so the - // user can start typing immediately. This is done after the grid has - // reconciled above so the target slot exists. The focus is guarded so - // a session the user is already interacting with is never re-focused - // (which would steal focus from the clicked element), and the id check - // ensures unrelated visibility updates do not move focus. - const activeId = active?.sessionId; - if (activeId !== this._focusedActiveSessionId) { - this._focusedActiveSessionId = activeId; - this._mainPart.focusSession(activeId); - } - })); - - // When a session view in the grid receives focus, promote that session to - // the active session. The id is guaranteed to correspond to a session in - // the visibility model (the part only fires for non-placeholder slots). - this._register(this._mainPart.onDidFocusSession(sessionId => { - const session = sessionsManagementService.visibleSessions.get().find(s => s?.sessionId === sessionId); - if (session) { - sessionsManagementService.setActive(session); - } - })); - } - - toggleMaximizeSession(session: IActiveSession | undefined): void { - if (!session) { - this._mainPart.toggleMaximizeSession(undefined); - return; - } - const maximized = this._mainPart.toggleMaximizeSession(session.sessionId); - if (maximized !== undefined) { - this._onDidToggleMaximizeSession.fire({ session, maximized }); - } - } - - focusSession(session: IActiveSession | undefined): void { - this._mainPart.focusSession(session?.sessionId); - } - - getSessionView(sessionId: string | undefined): SessionView | undefined { - return this._mainPart.getSessionView(sessionId); - } - - getProgressIndicator(): IProgressIndicator { - return this._mainPart.getProgressIndicator(); - } -} - -registerSingleton(ISessionsPartService, SessionsParts, InstantiationType.Eager); diff --git a/src/vs/sessions/browser/parts/sessionsParts.ts b/src/vs/sessions/browser/parts/sessionsParts.ts new file mode 100644 index 0000000000000..65fe535191ff3 --- /dev/null +++ b/src/vs/sessions/browser/parts/sessionsParts.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; +import { InstantiationType, registerSingleton } from '../../../platform/instantiation/common/extensions.js'; +import { getClientArea } from '../../../base/browser/dom.js'; +import { mainWindow } from '../../../base/browser/window.js'; +import { SessionsPart } from './sessionsPart.js'; +import { MobileSessionsPart } from './mobile/mobileSessionsPart.js'; +import { SessionView } from './sessionView.js'; +import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; +import { IProgressIndicator } from '../../../platform/progress/common/progress.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { ISessionsPartService, IToggleMaximizeSessionEvent } from './sessionsPartService.js'; + +/** + * Owns the lifecycle of the {@link SessionsPart}. Selects the mobile vs. desktop + * variant based on viewport width at construction time. Registered as an eager + * singleton so the part registers itself with the workbench layout service + * before the workbench starts laying out parts. + * + * The part is a passive renderer: the {@link ISessionsViewService} drives the + * grid via {@link updateVisibleSessions}/{@link focusSession} and listens to + * {@link onDidFocusSession}. The part observes neither the model nor the view. + */ +export class SessionsParts extends Disposable implements ISessionsPartService { + + declare readonly _serviceBrand: undefined; + + private readonly _mainPart: SessionsPart; + + private readonly _onDidToggleMaximizeSession = this._register(new Emitter()); + readonly onDidToggleMaximizeSession: Event = this._onDidToggleMaximizeSession.event; + + get onDidFocusSession(): Event { + return this._mainPart.onDidFocusSession; + } + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + const { width } = getClientArea(mainWindow.document.body); + const isPhoneLayout = width < 640; + + this._mainPart = this._register(instantiationService.createInstance(isPhoneLayout ? MobileSessionsPart : SessionsPart)); + } + + updateVisibleSessions(visible: readonly (IActiveSession | undefined)[], active: IActiveSession | undefined): void { + this._mainPart.updateVisibleSessions(visible, active); + } + + toggleMaximizeSession(session: IActiveSession | undefined): void { + if (!session) { + this._mainPart.toggleMaximizeSession(undefined); + return; + } + const maximized = this._mainPart.toggleMaximizeSession(session.sessionId); + if (maximized !== undefined) { + this._onDidToggleMaximizeSession.fire({ session, maximized }); + } + } + + focusSession(session: IActiveSession | undefined): void { + this._mainPart.focusSession(session?.sessionId); + } + + getSessionView(sessionId: string | undefined): SessionView | undefined { + return this._mainPart.getSessionView(sessionId); + } + + getProgressIndicator(): IProgressIndicator { + return this._mainPart.getProgressIndicator(); + } +} + +registerSingleton(ISessionsPartService, SessionsParts, InstantiationType.Eager); diff --git a/src/vs/sessions/browser/sessionsViewService.ts b/src/vs/sessions/browser/sessionsViewService.ts new file mode 100644 index 0000000000000..568d6188b32e4 --- /dev/null +++ b/src/vs/sessions/browser/sessionsViewService.ts @@ -0,0 +1,988 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { disposableTimeout } from '../../base/common/async.js'; +import { CancellationToken, CancellationTokenSource } from '../../base/common/cancellation.js'; +import { Emitter, Event } from '../../base/common/event.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../base/common/lifecycle.js'; +import { ResourceMap } from '../../base/common/map.js'; +import { IObservable, autorun } from '../../base/common/observable.js'; +import { URI } from '../../base/common/uri.js'; +import { createDecorator, IInstantiationService } from '../../platform/instantiation/common/instantiation.js'; +import { InstantiationType, registerSingleton } from '../../platform/instantiation/common/extensions.js'; +import { ILogService } from '../../platform/log/common/log.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../platform/storage/common/storage.js'; +import { IUriIdentityService } from '../../platform/uriIdentity/common/uriIdentity.js'; +import { IChat, ISession, SessionStatus } from '../services/sessions/common/session.js'; +import { IActiveSession, ICreateNewSessionOptions, IRecentlyOpenedSessions, ISessionsChangeEvent, ISessionsManagementService, IToggleSessionStickinessEvent } from '../services/sessions/common/sessionsManagement.js'; +import { ISessionsProvidersService } from '../services/sessions/browser/sessionsProvidersService.js'; +import { SessionsNavigation } from '../services/sessions/browser/sessionNavigation.js'; +import { SessionsRecencyHistory } from '../services/sessions/browser/sessionsRecencyHistory.js'; +import { VisibleSessions } from '../services/sessions/browser/visibleSessions.js'; +import { IContextKeyService } from '../../platform/contextkey/common/contextkey.js'; +import { ISessionsPartService } from './parts/sessionsPartService.js'; + +const ACTIVE_SESSION_STATES_KEY = 'agentSessions.activeSessionStates'; + +/** + * Upper bound on how long restore waits for a persisted session to resurface + * via its provider. Generous (providers may load after auth settles) but finite + * so a session that is gone for good cannot keep restore — and its provider + * listeners — alive indefinitely. + */ +const RESTORE_SESSION_WAIT_TIMEOUT = 30_000; + +/** Maximum number of recently opened sessions reported by {@link SessionsViewService.getRecentlyOpenedSessions}. */ +const MAX_RECENTLY_OPENED_SESSIONS = 10; + +/** + * Options for {@link ISessionsViewService.openNewSession}. + */ +export interface IOpenNewSessionOptions extends ICreateNewSessionOptions { + /** + * Folder to create a concrete draft session for. When set, a new draft is + * created and shown; when omitted, the new-session composer is shown + * (restoring any pending draft). + */ + readonly folderUri?: URI; +} + +/** + * Persisted state for a session. + * Extend this interface to store additional per-session state that should be + * remembered across restarts. + */ +interface ISessionState { + /** The resource URI of the session. */ + sessionResource: string; + /** The resource URI of the last active chat within the session. */ + activeChatResource?: string; + /** Whether this session was the active session at the time of save. */ + isActive?: boolean; + /** + * Position (left-to-right) of the session in the grid at save time, when + * the session was visible. `undefined` when the session was not visible. + */ + visibleOrder?: number; + /** Whether the session was pinned (sticky) in the grid at save time. */ + isSticky?: boolean; +} + +/** + * Owns the visible sessions shown in the sessions part's grid and everything + * that drives them: opening sessions/chats, the new-session composer view, + * grid arrangement (insert / stickiness / close), Back/Forward navigation, + * focus, and per-session view persistence (restore). + * + * This is the *view* counterpart to the *model* + * {@link ISessionsManagementService}: it reflects model changes reactively and + * pushes the visible active slot back into the model via + * {@link ISessionsManagementService.setActiveSession}. It never performs model + * lifecycle operations (creating sessions, sending requests, CRUD) itself — + * those stay in the management service. + */ +export interface ISessionsViewService { + readonly _serviceBrand: undefined; + + /** + * Observable list of slots currently displayed in the sessions part's + * grid, in their grid order (left-to-right). Each entry is either an + * {@link IActiveSession} or `undefined` for the empty (new-session) + * placeholder. At most one entry is `undefined` at a time. Sessions + * pinned via {@link toggleSessionStickiness} are sticky; the remaining + * non-sticky entries get replaced when new sessions are opened. + */ + readonly visibleSessions: IObservable; + + /** Fires after a session's stickiness was toggled via {@link toggleSessionStickiness}. */ + readonly onDidToggleSessionStickiness: Event; + + /** + * Get all sessions from all registered providers, split into two groups: + * - `recent`: sessions opened in this workspace, most recently opened first, + * capped at a fixed maximum. + * - `other`: the remaining sessions, sorted by their last update time (most + * recently updated first). + * + * Used to populate the sessions picker. + */ + getRecentlyOpenedSessions(): IRecentlyOpenedSessions; + + /** + * Select an existing session as the active session and show it in the grid. + * When `options.preserveFocus` is set, the session is shown without moving + * keyboard focus into it. + */ + openSession(sessionResource: URI, options?: { preserveFocus?: boolean }): Promise; + + /** + * Open a specific chat within a session and show it in the grid. + */ + openChat(session: ISession, chatUri: URI): Promise; + + /** + * Open the new-session composer. + * + * - Without `options.folderUri`: switch to the new-session view, restoring + * the pending (composed-but-not-sent) draft if one exists, otherwise + * showing the empty placeholder. No-op when the empty placeholder is + * already showing (no session active). Returns the restored pending + * draft, or `undefined` when none. + * - With `options.folderUri`: create a concrete draft session for that + * folder (via {@link ISessionsManagementService.createNewSession}) and + * show it as the active session. Returns the created draft. + */ + openNewSession(options?: IOpenNewSessionOptions): ISession | undefined; + + /** + * Switch to the new-chat-in-session view. + * Adds a new chat to the session via the provider, makes it the active chat, + * and shows a rich input for composing a message. + */ + openNewChatInSession(session: ISession): Promise; + + /** + * Discard the pending new session and clear the active session, returning + * to the empty new-session placeholder. + */ + unsetNewSession(): void; + + /** + * Insert (or move) a session into the grid positioned next to a target + * session that is already visible. + */ + insertAt(session: ISession, targetSessionId: string, side: 'left' | 'right', activate?: boolean): void; + + /** + * Toggle a session's stickiness in the grid. The session keeps its grid + * slot when toggled. If the session is not currently visible, it is + * appended to the grid as sticky. + */ + toggleSessionStickiness(session: ISession): void; + + /** + * Close a session: remove it from the grid. If it was the active one, the + * previous visible session becomes active; if no session remains visible, + * the new-session view is opened. Passing `undefined` closes the empty + * (new-session) slot if it is currently visible. + */ + closeSession(session: ISession | undefined): void; + + /** + * Close all sessions currently shown in the grid and land on the + * new-session view. No-op when no session is currently visible. + */ + closeAllSessions(): void; + + /** Make the given (already visible) session the active session. */ + setActive(session: IActiveSession | undefined): void; + + /** + * Restore the sessions that were visible in the grid from persisted state. + * Restores their order, sticky (pinned) state and the active session, + * waiting until each session's provider makes it available. Falls back to + * the new-session view when nothing can be restored. + */ + restoreVisibleSessions(): Promise; + + /** Navigate to the previous session in the navigation history. */ + openPreviousSession(): Promise; + + /** Navigate to the next session in the navigation history. */ + openNextSession(): Promise; +} + +export const ISessionsViewService = createDecorator('sessionsViewService'); + +export class SessionsViewService extends Disposable implements ISessionsViewService { + + declare readonly _serviceBrand: undefined; + + private readonly _onDidToggleSessionStickiness = this._register(new Emitter()); + readonly onDidToggleSessionStickiness: Event = this._onDidToggleSessionStickiness.event; + + /** Owns the active/sticky/transient visibility model and the {@link IActiveSession} wrappers. */ + private readonly _visibility: VisibleSessions; + readonly visibleSessions: IObservable; + + /** Cancelled on every navigation action so in-flight async opens bail out. */ + private readonly _openSessionCts = this._register(new MutableDisposable()); + /** + * Cancellation for the in-flight {@link restoreVisibleSessions}. Kept + * separate from {@link _openSessionCts} so that additive new-session + * operations (the new-chat composer eagerly creating a draft on startup) + * do not abort restoring the previously visible grid. Only an explicit + * navigation to a specific session cancels a restore. + */ + private readonly _restoreCts = this._register(new MutableDisposable()); + + private readonly _sessionStates: ResourceMap; + private readonly _navigation: SessionsNavigation; + /** + * The single source of truth for session recency (most-recently-opened + * first), persisted across restarts. Both the recent-sessions picker (via + * {@link getRecentlyOpenedSessions}) and {@link SessionsNavigation} build on + * top of it. + */ + private readonly _recencyHistory: SessionsRecencyHistory; + + /** + * Session id (or `undefined` for the new-session slot) that focus was last + * moved into in response to an active-session change. Tracks the active id + * so unrelated visibility updates don't re-focus and steal focus. + */ + private _focusedActiveSessionId: string | undefined; + + /** The in-flight foreground send's "keep newest chat active" follow. */ + private readonly _sendFollow = this._register(new MutableDisposable()); + + constructor( + @IStorageService private readonly storageService: IStorageService, + @ILogService private readonly logService: ILogService, + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, + @ISessionsPartService private readonly sessionsPartService: ISessionsPartService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + + // Load persisted state + this._sessionStates = this._loadSessionStates(); + + // Visibility model — owns wrappers, active/sticky/transient state, and + // observables exposed to the UI. + this._visibility = this._register(this.instantiationService.createInstance( + VisibleSessions, + session => this._restoreInitialChat(session), + )); + this.visibleSessions = this._visibility.visibleSessions; + + // Save on shutdown + this._register(this.storageService.onWillSaveState(() => this._saveSessionStates())); + + // Session recency history — the single source of truth for "recently + // opened" ordering, shared by the picker and navigation. + this._recencyHistory = this._register(new SessionsRecencyHistory( + this.storageService, + this.logService, + )); + + // Session navigation history (Back/Forward) builds on the recency history. + this._navigation = this._register(new SessionsNavigation( + this, + this.sessionsManagementService, + this._recencyHistory, + this.contextKeyService, + this.logService, + )); + this._register(this.sessionsManagementService.onDidChangeSessions(e => this._navigation.onDidRemoveSessions(e))); + this._register(this.sessionsManagementService.onDidDeleteSession(session => this._recencyHistory.remove(entry => entry.sessionResource.toString() === session.resource.toString()))); + + // Mirror the visible active slot into the model so the model's + // canonical `activeSession` always reflects what the user sees. + this._register(autorun(reader => { + const active = this._visibility.activeSession.read(reader); + this.sessionsManagementService.setActiveSession(active); + })); + + // Per-active-session view reactions (archived → new-session view, + // active-chat removed → fallback chat, persist the active chat). + this._register(autorun(reader => { + const activeSession = this.sessionsManagementService.activeSession.read(reader); + if (activeSession) { + reader.store.add(this._activeSessionViewListeners(activeSession)); + } + })); + + // Reflect provider-level session changes onto the grid: drop removed + // sessions and pick a fallback (or the new-session view) when the active + // one disappears. + this._register(this.sessionsManagementService.onDidChangeSessions(e => this._onDidChangeSessions(e))); + + // Reflect provider session replacement (e.g. a draft graduating into a + // committed session) onto the grid slot. + this._register(this.sessionsManagementService.onDidReplaceSession(({ from, to }) => this._visibility.updateSession(from, to))); + + // While a foreground send materialises new chats, keep the newest chat + // active in the visible slot so the user sees the chat being sent. + this._register(this.sessionsManagementService.onWillSendRequest(session => this._startSendFollow(session))); + this._register(this.sessionsManagementService.onDidSendRequest(() => this._sendFollow.clear())); + + // Drive the part: reconcile the grid and move focus into the active + // session whenever the visible sessions or the active session change. + this._register(autorun(reader => { + const visible = this.visibleSessions.read(reader); + const active = this._visibility.activeSession.read(reader); + const preserveFocus = this._visibility.activePreserveFocus.read(reader); + this.sessionsPartService.updateVisibleSessions(visible, active); + + // Move keyboard focus into the active session whenever it changes + // (e.g. after opening, switching to, or restoring a session) so the + // user can start typing immediately. The focus is guarded so a + // session the user is already interacting with is never re-focused + // (which would steal focus from the clicked element), and the id + // check ensures unrelated visibility updates do not move focus. + // `preserveFocus` (published atomically with the active session) + // suppresses the focus move for background opens. + const activeId = active?.sessionId; + if (activeId !== this._focusedActiveSessionId) { + this._focusedActiveSessionId = activeId; + if (!preserveFocus) { + this.sessionsPartService.focusSession(active); + } + } + })); + + // When a session view in the grid receives focus, promote that session + // to the active session. + this._register(this.sessionsPartService.onDidFocusSession(sessionId => { + const session = this.visibleSessions.get().find(s => s?.sessionId === sessionId); + if (session) { + this.setActive(session); + } + })); + } + + private _activeSessionViewListeners(activeSession: IActiveSession): IDisposable { + const disposables = new DisposableStore(); + + // When the active session becomes archived, return to the new-session view. + let wasArchived = activeSession.isArchived.get(); + disposables.add(autorun(reader => { + const isArchived = activeSession.isArchived.read(reader); + if (isArchived && !wasArchived) { + this.openNewSession(); + } + wasArchived = isArchived; + })); + + // Track chat list changes — if the active chat is removed, fall back. + if (activeSession.status.get() !== SessionStatus.Untitled) { + disposables.add(autorun(reader => { + const chats = activeSession.chats.read(reader); + const activeChat = activeSession.activeChat.read(reader); + if (activeChat && !chats.some(c => this.uriIdentityService.extUri.isEqual(c.resource, activeChat.resource))) { + const fallback = chats[chats.length - 1] ?? activeSession.mainChat.read(reader); + if (fallback) { + this.openChat(activeSession, fallback.resource); + } + } + })); + } + + // Track active chat changes to persist per-session state. The visible / + // active / sticky flags are snapshotted from the live grid at save time + // (see `_snapshotVisibleSessionStates`); here we only remember the last + // active chat so reopening the session restores its selected chat. + disposables.add(autorun(reader => { + const chat = activeSession.activeChat.read(reader); + if (chat && chat.status.read(undefined) !== SessionStatus.Untitled) { + const existing = this._sessionStates.get(activeSession.resource); + this._sessionStates.set(activeSession.resource, { + ...existing, + sessionResource: activeSession.resource.toString(), + activeChatResource: chat.resource.toString(), + }); + } + })); + + return disposables; + } + + private _onDidChangeSessions(e: ISessionsChangeEvent): void { + const currentActive = this._visibility.activeSession.get(); + + // Clean removed sessions out of the visibility model (drops their grid + // slot and disposes their wrapper). If the active session is among the + // removed, removeMany picks a fallback active session (or clears it when + // no slot remains); drive the open flow below so the fallback is fully + // opened. + if (e.removed.length) { + this._visibility.removeMany(e.removed.map(r => r.sessionId)); + } + + if (!currentActive) { + return; + } + + if (e.removed.length && e.removed.some(r => r.sessionId === currentActive.sessionId)) { + const fallback = this._visibility.activeSession.get(); + if (fallback && this.sessionsManagementService.getSession(fallback.resource)) { + this.openSession(fallback.resource); + } else { + this.openNewSession(); + } + } + } + + private _startSendFollow(session: ISession): void { + const store = new DisposableStore(); + let followId = session.sessionId; + // A foreground send can replace the session id (draft graduating into a + // committed session); keep following the new id. + store.add(this.sessionsManagementService.onDidReplaceSession(({ from, to }) => { + if (from.sessionId === followId) { + followId = to.sessionId; + } + })); + store.add(autorun(reader => { + const active = this._visibility.activeSession.read(reader); + if (active && active.sessionId === followId) { + const chats = active.chats.read(reader); + const lastChat = chats[chats.length - 1]; + if (lastChat) { + this._visibility.setActiveChat(active, lastChat); + } + } + })); + this._sendFollow.value = store; + } + + getRecentlyOpenedSessions(): IRecentlyOpenedSessions { + const seen = new Set(); + const recent: ISession[] = []; + + // Sessions in recency order (most-recently-opened first), deduplicated by + // session so a session with multiple opened chats appears only once and + // capped at the most recent {@link MAX_RECENTLY_OPENED_SESSIONS}. + for (const entry of this._recencyHistory.entries) { + if (recent.length >= MAX_RECENTLY_OPENED_SESSIONS) { + break; + } + const key = entry.sessionResource.toString(); + if (seen.has(key)) { + continue; + } + seen.add(key); + const session = this.sessionsManagementService.getSession(entry.sessionResource); + if (session) { + recent.push(session); + } + } + + // Sessions that have not been included in the recently opened group, + // sorted by most recently updated first. + const other = this.sessionsManagementService.getSessions() + .filter(s => !seen.has(s.resource.toString())) + .sort((a, b) => b.updatedAt.get().getTime() - a.updatedAt.get().getTime()); + + return { recent, other }; + } + + /** + * Cancel any in-flight open-session/restore and return a fresh cancellation token. + */ + private _startOpenSession(): CancellationToken { + this._openSessionCts.value?.cancel(); + const cts = new CancellationTokenSource(); + this._openSessionCts.value = cts; + return cts.token; + } + + /** + * Cancel an in-flight {@link restoreVisibleSessions}. Called when the user + * explicitly navigates to a specific session, so restore stops fighting + * the user's choice. Additive new-session operations do NOT call this. + */ + private _cancelRestore(): void { + // `cancel()` (not just `clear()`/dispose) so the in-flight restore's + // token actually fires cancellation and bails out; `MutableDisposable` + // disposes the source without cancelling it. + this._restoreCts.value?.cancel(); + this._restoreCts.clear(); + } + + /** + * Make the given session active in the visibility model, optionally without + * moving focus into it. The preserve-focus intent is published atomically + * with the active session by the visibility model, and the model's + * canonical active session is updated reactively by the mirror autorun. + */ + private _activate(session: ISession | undefined, preserveFocus?: boolean): void { + this._visibility.setActive(session, preserveFocus); + } + + async openChat(session: ISession, chatUri: URI): Promise { + const t0 = Date.now(); + this._cancelRestore(); + const token = this._startOpenSession(); + this.logService.trace(`[SessionsView] openChat start uri=${chatUri.toString()} provider=${session.providerId}`); + this._activate(session); + if (!await this._waitForSessionToLoad(session, token)) { + this.logService.trace(`[SessionsView] openChat cancelled while waiting for session to load uri=${chatUri.toString()}`); + return; + } + + // Find the chat and update active chat + let chat: IChat | undefined; + const activeSession = this._visibility.activeSession.get(); + if (activeSession) { + chat = activeSession.chats.get().find(c => this.uriIdentityService.extUri.isEqual(c.resource, chatUri)); + if (chat) { + this._visibility.setActiveChat(session, chat); + } + } + + if (chat && chat.status.get() === SessionStatus.Untitled) { + this.logService.trace(`[SessionsView] openChat done total=${Date.now() - t0}ms uri=${chatUri.toString()} path=untitled`); + return; + } + + this.logService.trace(`[SessionsView] openChat done total=${Date.now() - t0}ms uri=${chatUri.toString()}`); + } + + async openSession(sessionResource: URI, options?: { preserveFocus?: boolean }): Promise { + this._cancelRestore(); + const token = this._startOpenSession(); + await this._doOpenSession(sessionResource, token, options); + } + + private async _doOpenSession(sessionResource: URI, token: CancellationToken, options?: { preserveFocus?: boolean }): Promise { + const t0 = Date.now(); + const sessionData = this.sessionsManagementService.getSession(sessionResource); + if (!sessionData) { + this.logService.warn(`[SessionsView] openSession: session not found uri=${sessionResource.toString()}`); + throw new Error(`Session with resource ${sessionResource.toString()} not found`); + } + this.logService.trace(`[SessionsView] openSession start uri=${sessionResource.toString()} provider=${sessionData.providerId}`); + this._activate(sessionData, options?.preserveFocus); + if (!await this._waitForSessionToLoad(sessionData, token)) { + this.logService.trace(`[SessionsView] openSession cancelled while waiting for session to load uri=${sessionResource.toString()}`); + return; + } + + this.logService.trace(`[SessionsView] openSession done total=${Date.now() - t0}ms uri=${sessionResource.toString()}`); + } + + unsetNewSession(): void { + this.sessionsManagementService.discardNewSession(); + this._activate(undefined); + } + + openNewSession(options?: IOpenNewSessionOptions): ISession | undefined { + const folderUri = options?.folderUri; + if (folderUri) { + this._startOpenSession(); + const session = this.sessionsManagementService.createNewSession(folderUri, options); + this._activate(session); + return session; + } + + // Without a folder: switch to the new-session composer view. + // No-op when no session is active (empty new-session placeholder showing). + if (this._visibility.activeSession.get() === undefined) { + return undefined; + } + this._startOpenSession(); + + // Restore the in-progress new session if one exists, so pickers re-derive + // their state from the still-alive session object. Otherwise clear the + // active session (first time / after send). + const newSession = this.sessionsManagementService.newSession.get(); + this._activate(newSession ?? undefined); + return newSession ?? undefined; + } + + async openNewChatInSession(session: ISession): Promise { + this._cancelRestore(); + this._startOpenSession(); + const chat = await this.sessionsManagementService.createNewChatInSession(session); + if (!chat) { + return; + } + + this._activate(session); + + // Set the chat as the active chat + this._visibility.setActiveChat(session, chat); + } + + setActive(session: IActiveSession | undefined): void { + this._activate(session); + } + + toggleSessionStickiness(session: ISession): void { + const sticky = this._visibility.toggleStickiness(session); + this._onDidToggleSessionStickiness.fire({ session, sticky }); + } + + insertAt(session: ISession, targetSessionId: string, side: 'left' | 'right', activate: boolean = true): void { + this._visibility.insertAt(session, targetSessionId, side, activate); + } + + closeSession(session: ISession | undefined): void { + const sessionId = session?.sessionId; + const visible = this._visibility.visibleSessions.get(); + if (!visible.some(s => s?.sessionId === sessionId)) { + return; + } + + // The empty/new-session slot has no sessionId; both it and "no active + // session" are reported by activeSession as undefined. Since we already + // confirmed the slot is present in `visible`, undefined === undefined + // here means the empty slot is active. + const activeSessionId = this._visibility.activeSession.get()?.sessionId; + const wasActive = activeSessionId === sessionId; + + // Discard the in-progress new session when its slot (or the empty slot) + // is the one being closed; closing an unrelated session leaves it intact. + this.sessionsManagementService.discardNewSession(session); + + this._visibility.removeMany([sessionId]); + + if (!wasActive) { + return; + } + + // removeMany already picked a fallback active session (or cleared the + // active observable when no slot remains); drive the full open flow. + const fallback = this._visibility.activeSession.get(); + if (fallback === undefined) { + this.openNewSession(); + } + } + + closeAllSessions(): void { + const ids = this._visibility.visibleSessions.get() + .filter((s): s is IActiveSession => !!s) + .map(s => s.sessionId); + if (ids.length === 0) { + return; + } + + this.sessionsManagementService.discardNewSession(); + + // Remove every visible session in a single pass; the visibility model + // clears the active session, which drives the grid back to the + // new-session view via the reconcile autorun. + this._visibility.removeMany(ids); + } + + private _restoreInitialChat(session: ISession): IChat { + const chats = session.chats.get(); + let initialChat = chats[0]; + const sessionState = this._sessionStates.get(session.resource); + if (sessionState?.activeChatResource) { + try { + const lastChatResource = URI.parse(sessionState.activeChatResource); + const found = chats.find(c => this.uriIdentityService.extUri.isEqual(c.resource, lastChatResource)); + if (found) { + initialChat = found; + } + } catch (error) { + this.logService.warn('[SessionsView] Failed to restore active chat from stored session state', error); + } + } + return initialChat; + } + + private async _waitForSessionToLoad(session: ISession, token: CancellationToken): Promise { + if (!session.loading.get()) { + return true; + } + if (token.isCancellationRequested) { + return false; + } + + await new Promise(resolve => { + const disposables = new DisposableStore(); + let resolved = false; + const finish = () => { + if (resolved) { + return; + } + resolved = true; + disposables.dispose(); + resolve(); + }; + + disposables.add(token.onCancellationRequested(finish)); + disposables.add(autorun(reader => { + if (!session.loading.read(reader)) { + finish(); + } + })); + }); + + return !token.isCancellationRequested; + } + + private _loadSessionStates(): ResourceMap { + const map = new ResourceMap(); + const raw = this.storageService.get(ACTIVE_SESSION_STATES_KEY, StorageScope.WORKSPACE); + if (!raw) { + return map; + } + try { + const entries: ISessionState[] = JSON.parse(raw); + for (const entry of entries) { + const uri = URI.parse(entry.sessionResource); + map.set(uri, entry); + } + } catch { + // ignore corrupt data + } + return map; + } + + private _saveSessionStates(): void { + const entries = this._snapshotVisibleSessionStates(); + this.storageService.store(ACTIVE_SESSION_STATES_KEY, JSON.stringify(entries), StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + + private _snapshotVisibleSessionStates(): ISessionState[] { + const activeId = this._visibility.activeSession.get()?.sessionId; + const visible = this._visibility.visibleSessions.get(); + const entries: ISessionState[] = []; + visible.forEach((session, index) => { + if (!session) { + return; + } + + if (session.status.get() === SessionStatus.Untitled) { + this._sessionStates.delete(session.resource); + return; + } + + // Keep the in-memory record up to date so the session's last active + // chat is remembered while reopening it within this window. + const existing = this._sessionStates.get(session.resource); + const state: ISessionState = { + sessionResource: session.resource.toString(), + activeChatResource: session.activeChat.get()?.resource.toString() ?? existing?.activeChatResource, + visibleOrder: index, + isSticky: session.sticky.get(), + isActive: session.sessionId === activeId, + }; + this._sessionStates.set(session.resource, state); + entries.push(state); + }); + return entries; + } + + /** + * The persisted visible sessions, ordered left-to-right by their stored + * grid position. + */ + private _getVisibleSessionStates(): ISessionState[] { + const states: ISessionState[] = []; + for (const [, state] of this._sessionStates) { + if (state.visibleOrder !== undefined) { + states.push(state); + } + } + return states.sort((a, b) => (a.visibleOrder! - b.visibleOrder!)); + } + + /** + * Wait for the session with the given resource to become available via its + * provider, resolving with the session or `undefined` if the token is + * cancelled before it appears. When `timeout` is given, resolves with + * `undefined` after that many milliseconds so a persisted session that never + * resurfaces (e.g. deleted while the window was closed) cannot keep restore + * pending — and its provider listeners alive — indefinitely. + */ + private _waitForSession(sessionResource: URI, token: CancellationToken, timeout?: number): Promise { + const existing = this.sessionsManagementService.getSession(sessionResource); + if (existing) { + return Promise.resolve(existing); + } + return new Promise(resolve => { + const disposables = new DisposableStore(); + let resolved = false; + const finish = (session: ISession | undefined) => { + if (resolved) { + return; + } + resolved = true; + disposables.dispose(); + resolve(session); + }; + + disposables.add(token.onCancellationRequested(() => finish(undefined))); + + const tryFind = () => { + if (token.isCancellationRequested) { + finish(undefined); + return; + } + const session = this.sessionsManagementService.getSession(sessionResource); + if (session) { + finish(session); + } + }; + + // Providers (e.g. the agent host) load their session cache + // asynchronously, so the session may appear via either a provider + // change or a session list change. + disposables.add(this.sessionsProvidersService.onDidChangeProviders(() => tryFind())); + disposables.add(this.sessionsManagementService.onDidChangeSessions(() => tryFind())); + + // Give up after the timeout so the listeners above are not retained + // forever when the session is gone for good. + if (timeout !== undefined) { + disposables.add(disposableTimeout(() => finish(undefined), timeout)); + } + + // In case the session became available between the initial check and + // the listener registration. + tryFind(); + }); + } + + async restoreVisibleSessions(): Promise { + // Ordered list of slots to restore: real sessions plus, optionally, the + // empty (new-session) slot when it was active. + interface IRestoreTarget { + readonly resource: URI | undefined; + readonly isSticky: boolean; + readonly isActive: boolean; + readonly order: number; + } + + const targets: IRestoreTarget[] = this._getVisibleSessionStates().map(state => ({ + resource: URI.parse(state.sessionResource), + isSticky: !!state.isSticky, + isActive: !!state.isActive, + order: state.visibleOrder!, + })); + + if (targets.length === 0) { + targets.push({ resource: undefined, isSticky: false, isActive: true, order: 1 }); + } + + targets.sort((a, b) => a.order - b.order); + + let activeIdx = targets.findIndex(t => t.isActive); + if (activeIdx < 0) { + activeIdx = 0; + } + + // Use a dedicated cancellation token (not the shared open-session one) + // so that a new-session draft created during restore (e.g. by the + // new-chat composer on startup) does not abort restoring the grid. The + // token is cancelled only when the user explicitly opens a session. + const cts = new CancellationTokenSource(); + this._restoreCts.value = cts; + const token = cts.token; + + // Sessions resolved so far, indexed by their position in `targets`. + // `null` marks the empty (new-session) slot, which has no session. + const resolved: (ISession | null | undefined)[] = new Array(targets.length).fill(undefined); + + /** + * Insert a resolved session into the grid next to the nearest + * already-placed neighbour, preserving the persisted order regardless of + * the order in which sessions become available. When a neighbour exists + * the active session is left unchanged; only in the edge case where no + * neighbour has been placed yet (e.g. the active target never resurfaced, + * so the grid laid out empty) does the first session to arrive become + * active as a sensible fallback. + */ + const place = (idx: number, session: ISession): void => { + let anchor: { id: string | undefined; side: 'left' | 'right' } | undefined; + for (let j = idx - 1; j >= 0 && !anchor; j--) { + const neighbour = resolved[j]; + if (neighbour !== undefined) { + anchor = { id: neighbour?.sessionId, side: 'right' }; + } + } + for (let j = idx + 1; j < targets.length && !anchor; j++) { + const neighbour = resolved[j]; + if (neighbour !== undefined) { + anchor = { id: neighbour?.sessionId, side: 'left' }; + } + } + + resolved[idx] = session; + if (anchor) { + this._visibility.insertAt(session, anchor.id, anchor.side, false); + } else { + this._activate(session); + } + if (targets[idx].isSticky) { + this._visibility.toggleStickiness(session); + } + }; + + // Resolve the active session first so it can act as the anchor for the + // initial layout. The empty slot resolves immediately (the grid already + // shows the new-session view). Load progress is surfaced per-leaf by the + // chat view itself once the grid is laid out (mirroring how each editor + // group owns its progress bar), so no part-wide progress is driven here. + const activeTarget = targets[activeIdx]; + const activeSessionPromise: Promise = activeTarget.resource + ? this._waitForSession(activeTarget.resource, token, RESTORE_SESSION_WAIT_TIMEOUT).then(session => session ?? undefined) + : Promise.resolve(undefined); + + const activeSession = await activeSessionPromise; + + if (token.isCancellationRequested) { + return; + } + + // Lay out all currently-available sessions atomically in the persisted + // order so the grid appears in one shot rather than building up slot by + // slot (which caused the active session to be shown alone and then + // reflow as the others were inserted). Sessions whose provider has not + // yet surfaced them are filled in incrementally below. + const slots: { session: ISession | undefined; sticky: boolean }[] = []; + let activeSlotIndex = -1; + for (let idx = 0; idx < targets.length; idx++) { + const target = targets[idx]; + let session: ISession | null | undefined; + if (!target.resource) { + session = null; // empty new-session slot + } else if (idx === activeIdx) { + session = activeSession; + } else { + session = this.sessionsManagementService.getSession(target.resource); + } + if (session === undefined) { + continue; // not yet available — placed incrementally below + } + resolved[idx] = session; + if (idx === activeIdx) { + activeSlotIndex = slots.length; + } + slots.push({ session: session ?? undefined, sticky: target.isSticky }); + } + this._visibility.restoreGrid(slots, activeSlotIndex); + + if (token.isCancellationRequested) { + return; + } + + // Focus is moved into the restored active session by the reconcile + // autorun, which observes the active-session change. + + // Place any sessions that became available later in their correct + // positions around the already-established layout. + await Promise.all(targets.map(async (target, idx) => { + if (idx === activeIdx || !target.resource || token.isCancellationRequested || resolved[idx] !== undefined) { + return; + } + const session = await this._waitForSession(target.resource, token, RESTORE_SESSION_WAIT_TIMEOUT); + if (!session || token.isCancellationRequested || resolved[idx] !== undefined) { + return; + } + place(idx, session); + })); + } + + // -- Session Navigation -- + + async openPreviousSession(): Promise { + await this._navigation.goBack(); + } + + async openNextSession(): Promise { + await this._navigation.goForward(); + } +} + +registerSingleton(ISessionsViewService, SessionsViewService, InstantiationType.Eager); diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index d2a702ef5b573..c33ec29f7abfb 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -75,6 +75,7 @@ import { MobileTitlebarPart } from './parts/mobile/mobileTitlebarPart.js'; import { IMobileVisualViewport } from './parts/mobile/mobileVisualViewport.js'; import { autorun } from '../../base/common/observable.js'; import { ISessionsManagementService } from '../services/sessions/common/sessionsManagement.js'; +import { ISessionsViewService } from './sessionsViewService.js'; import { ISessionsPartService } from './parts/sessionsPartService.js'; import { ISessionsSetUpService } from './sessionsSetUpService.js'; @@ -317,6 +318,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic private paneCompositeService!: IPaneCompositePartService; private viewDescriptorService!: IViewDescriptorService; private sessionsManagementService!: ISessionsManagementService; + private sessionsViewService!: ISessionsViewService; private sessionsPartService!: ISessionsPartService; private instantiationService!: IInstantiationService; private storageService!: IStorageService; @@ -785,7 +787,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // so the new session view becomes visible. createMobileTitlebar() is // only invoked in phone layout, so closing the drawer here is safe. this.mobileTopBarDisposables.add(mobileTitlebar.onDidClickNewSession(() => { - this.sessionsManagementService.openNewSessionView({ inheritWorkspaceFromActiveSession: true }); + this.sessionsViewService.openNewSession(); this.closeMobileSidebarDrawer(); this.sessionsPartService.focusSession(this.sessionsManagementService.activeSession.get()); })); @@ -948,7 +950,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.restoreParts(); // Restore the sessions that were visible in the grid. - void this.sessionsManagementService.restoreVisibleSessions().catch(e => { + void this.sessionsViewService.restoreVisibleSessions().catch(e => { this.logService.error('[Workbench] restoreVisibleSessions failed', e); }); @@ -995,6 +997,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.paneCompositeService = accessor.get(IPaneCompositePartService); this.viewDescriptorService = accessor.get(IViewDescriptorService); this.sessionsManagementService = accessor.get(ISessionsManagementService); + this.sessionsViewService = accessor.get(ISessionsViewService); // Forces eager creation of the sessions part so it registers itself with the // layout service before renderWorkbench() looks it up via getPart(). this.sessionsPartService = accessor.get(ISessionsPartService); diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index 5231831dfef30..404ff8b3fd330 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -11,6 +11,7 @@ import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurati import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsPartService } from '../../../browser/parts/sessionsPartService.js'; +import { ISessionsViewService } from '../../../browser/sessionsViewService.js'; import { BranchChatSessionAction } from './branchChatSessionAction.js'; import { RunScriptContribution } from './runScriptAction.js'; import './nullInlineChatSessionService.js'; @@ -65,8 +66,9 @@ class NewChatInSessionsWindowAction extends Action2 { override run(accessor: ServicesAccessor): void { const sessionsManagementService = accessor.get(ISessionsManagementService); + const sessionsViewService = accessor.get(ISessionsViewService); const sessionsPartService = accessor.get(ISessionsPartService); - sessionsManagementService.openNewSessionView({ inheritWorkspaceFromActiveSession: true }); + sessionsViewService.openNewSession(); sessionsPartService.focusSession(sessionsManagementService.activeSession.get()); } } diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index 5725936747689..b591f4b0b345e 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -14,6 +14,7 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { localize } from '../../../../nls.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISession } from '../../../services/sessions/common/session.js'; +import { ISessionsViewService } from '../../../browser/sessionsViewService.js'; import { IAquariumService, IMountedToggleHandle } from '../../aquarium/browser/aquariumOverlay.js'; import { IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { WorkspacePicker } from './sessionWorkspacePicker.js'; @@ -47,6 +48,7 @@ export class NewChatWidget extends Disposable { @IInstantiationService private readonly instantiationService: IInstantiationService, @ILogService private readonly logService: ILogService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @ISessionsViewService private readonly sessionsViewService: ISessionsViewService, @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, @IAquariumService private readonly aquariumService: IAquariumService, @IAgentHostFilterService private readonly agentHostFilterService: IAgentHostFilterService, @@ -118,8 +120,8 @@ export class NewChatWidget extends Disposable { // 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). + // Skip if an active session already exists (restored by openNewSession + // from a new-session draft when navigating back from another session). const restoredFolderUri = this._workspacePicker.selectedFolderUri; if (!this._syncWorkspacePickerFromActiveSession() && restoredFolderUri) { this._createNewSession(restoredFolderUri, this._newChatInput.sessionTypePicker.selectedPick); @@ -129,7 +131,7 @@ export class NewChatWidget extends Disposable { } /** - * If a pending session was restored by {@link openNewSessionView}, sync + * If a new-session draft was restored by {@link openNewSession}, sync * the workspace picker to match the session's workspace. The picker may * have restored a workspace from a different provider (e.g. remote vs * local), so overwrite it with the session's actual workspace without @@ -175,11 +177,14 @@ export class NewChatWidget extends Disposable { const effectivePick = pick && this._isPreferredServable(folderUri, pick) ? pick : undefined; const fallbackProviderId = this._workspacePicker.selectedResolved?.providerId; try { - return this.sessionsManagementService.createNewSession(folderUri, effectivePick - ? { providerId: effectivePick.providerId, sessionTypeId: effectivePick.sessionTypeId } - : fallbackProviderId - ? { providerId: fallbackProviderId } - : undefined); + return this.sessionsViewService.openNewSession({ + folderUri, + ...(effectivePick + ? { providerId: effectivePick.providerId, sessionTypeId: effectivePick.sessionTypeId } + : fallbackProviderId + ? { providerId: fallbackProviderId } + : undefined), + }); } catch (e) { this.logService.error('Failed to create new session:', e); return undefined; @@ -387,7 +392,7 @@ export class NewChatWidget extends Disposable { this._pendingPreferredUpgrade.clear(); if (!folderUri) { - this.sessionsManagementService.unsetNewSession(); + this.sessionsViewService.unsetNewSession(); return; } diff --git a/src/vs/sessions/contrib/chat/browser/sessionsOpenerParticipant.ts b/src/vs/sessions/contrib/chat/browser/sessionsOpenerParticipant.ts index e9ebfd1820008..4fe9237b9c57c 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsOpenerParticipant.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsOpenerParticipant.ts @@ -9,6 +9,7 @@ import { IWorkbenchContribution } from '../../../../workbench/common/contributio import { IAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { ISessionOpenerParticipant, ISessionOpenOptions, sessionOpenerRegistry } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsViewService } from '../../../browser/sessionsViewService.js'; /** * Routes session open requests in the Agents window through the @@ -21,12 +22,13 @@ class SessionsOpenerParticipant implements ISessionOpenerParticipant { async handleOpenSession(accessor: ServicesAccessor, session: IAgentSession, openOptions?: ISessionOpenOptions): Promise { const sessionsManagementService = accessor.get(ISessionsManagementService); + const sessionsViewService = accessor.get(ISessionsViewService); const target = sessionsManagementService.getSession(session.resource); if (!target) { return false; } - await sessionsManagementService.openSession(session.resource, { preserveFocus: openOptions?.editorOptions?.preserveFocus }); + await sessionsViewService.openSession(session.resource, { preserveFocus: openOptions?.editorOptions?.preserveFocus }); return true; } } diff --git a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts index 2b9c231e469c2..ad8dda5440bcb 100644 --- a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts @@ -10,6 +10,7 @@ import { autorun } from '../../../../base/common/observable.js'; import { timeout } from '../../../../base/common/async.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsViewService } from '../../../browser/sessionsViewService.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { ILifecycleService, LifecyclePhase } from '../../../../workbench/services/lifecycle/common/lifecycle.js'; @@ -27,6 +28,7 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon constructor( @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @ISessionsViewService private readonly sessionsViewService: ISessionsViewService, @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, @IViewsService private readonly viewsService: IViewsService, @ILifecycleService private readonly lifecycleService: ILifecycleService, @@ -68,7 +70,7 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon // so the session-type picker re-reads the freshly stored // preference from storage. this.lifecycleService.when(LifecyclePhase.Eventually) - .then(() => this.sessionsManagementService.unsetNewSession()) + .then(() => this.sessionsViewService.unsetNewSession()) .catch(err => this.logService.error('[AgentsHandoff] preferred-only unsetNewSession failed', err)); } }; @@ -122,7 +124,7 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon const targetKey = sessionResource.toString(); for (let attempt = 0; attempt < 6; attempt++) { try { - await this.sessionsManagementService.openSession(sessionResource); + await this.sessionsViewService.openSession(sessionResource); const active = this.sessionsManagementService.activeSession.get(); if (active && active.resource.toString() === targetKey) { this.logService.info('[AgentsHandoff] openSession succeeded'); @@ -245,7 +247,7 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon // Wait for the welcome/setup flow to complete before selecting the folder await this.sessionsSetUpService.whenWelcomeDone(); - this.sessionsManagementService.openNewSessionView(); + this.sessionsViewService.openNewSession(); // Tell the sessions list this folder is the open-window source folder // so it ranks the matching folder section first. Get the view if it diff --git a/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts b/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts index c4d4742445088..f66837e1ec08b 100644 --- a/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts +++ b/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts @@ -165,8 +165,8 @@ suite('CodeReviewService', () => { } } - setActiveSession(resource: URI | undefined): void { - this._activeSession.set(resource ? this._sessions.get(resource.toString()) as IActiveSession | undefined : undefined, undefined); + override setActiveSession(session: ISession | undefined): void { + this._activeSession.set(session as IActiveSession | undefined, undefined); } updateSessionChanges(resource: URI, changes: readonly IChatSessionFileChange2[] | undefined): void { @@ -326,7 +326,7 @@ suite('CodeReviewService', () => { sessionsManagement.setGitHubInfo(session, makeGitHubInfo()); gitHubService.reviewThreadsFetcher.nextThreads = [makePRThread('thread-100', 'src/a.ts')]; - sessionsManagement.setActiveSession(session); + sessionsManagement.setActiveSession(sessionsManagement.getSession(session)); await tick(); // Polling is owned by GitHubPullRequestPollingContribution; refresh diff --git a/src/vs/sessions/contrib/layout/browser/sessionLayoutController.ts b/src/vs/sessions/contrib/layout/browser/sessionLayoutController.ts index c4f3a19d2258c..38809e647d72e 100644 --- a/src/vs/sessions/contrib/layout/browser/sessionLayoutController.ts +++ b/src/vs/sessions/contrib/layout/browser/sessionLayoutController.ts @@ -23,6 +23,7 @@ import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/l import { IPaneCompositePartService } from '../../../../workbench/services/panecomposite/browser/panecomposite.js'; import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsViewService } from '../../../browser/sessionsViewService.js'; import { SessionStatus } from '../../../services/sessions/common/session.js'; import { CHANGES_VIEW_ID } from '../../changes/common/changes.js'; import { SESSIONS_FILES_CONTAINER_ID } from '../../files/browser/files.contribution.js'; @@ -76,6 +77,7 @@ export class LayoutController extends Disposable { constructor( @IWorkbenchLayoutService private readonly _layoutService: IWorkbenchLayoutService, @ISessionsManagementService private readonly _sessionManagementService: ISessionsManagementService, + @ISessionsViewService private readonly _sessionsViewService: ISessionsViewService, @IChatService private readonly _chatService: IChatService, @IViewsService private readonly _viewsService: IViewsService, @IPaneCompositePartService private readonly _paneCompositePartService: IPaneCompositePartService, @@ -122,7 +124,7 @@ export class LayoutController extends Disposable { }); const multipleSessionsVisibleObs = derived(reader => { - return this._sessionManagementService.visibleSessions.read(reader).length > 1; + return this._sessionsViewService.visibleSessions.read(reader).length > 1; }); // When multiple sessions are visible, drop per-session view/panel state @@ -130,7 +132,7 @@ export class LayoutController extends Disposable { // This will ensure the default visibility logic will be used again after // closing all visible session and opening an existing one this._register(autorun(reader => { - const visibleSessions = this._sessionManagementService.visibleSessions.read(reader); + const visibleSessions = this._sessionsViewService.visibleSessions.read(reader); if (visibleSessions.length <= 1) { return; } @@ -426,7 +428,7 @@ export class LayoutController extends Disposable { private _saveState(): void { const activeSession = this._sessionManagementService.activeSession.get(); - const multipleVisible = this._sessionManagementService.visibleSessions.get().length > 1; + const multipleVisible = this._sessionsViewService.visibleSessions.get().length > 1; // Capture current state for the active session (skip when multiple sessions are visible) if (activeSession && !multipleVisible) { diff --git a/src/vs/sessions/contrib/layout/test/browser/sessionLayoutController.test.ts b/src/vs/sessions/contrib/layout/test/browser/sessionLayoutController.test.ts index ffca9c2fb2629..b303e29fe77e6 100644 --- a/src/vs/sessions/contrib/layout/test/browser/sessionLayoutController.test.ts +++ b/src/vs/sessions/contrib/layout/test/browser/sessionLayoutController.test.ts @@ -25,6 +25,7 @@ import { IPaneCompositePartService } from '../../../../../workbench/services/pan import { IPaneComposite } from '../../../../../workbench/common/panecomposite.js'; import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; import { IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsViewService } from '../../../../browser/sessionsViewService.js'; import { IChat, ISessionFileChange, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; import { LayoutController } from '../../browser/sessionLayoutController.js'; import { CHANGES_VIEW_ID } from '../../../changes/common/changes.js'; @@ -137,10 +138,12 @@ suite('LayoutController', () => { instaService.stub(ISessionsManagementService, new class extends mock() { override activeSession = activeSessionObs; - override readonly visibleSessions = constObservable([]); override readonly onDidChangeSessions = onDidChangeSessions.event; override getSessions() { return []; } }); + instaService.stub(ISessionsViewService, new class extends mock() { + override readonly visibleSessions = constObservable([]); + }); onDidSubmitRequest = store.add(new Emitter<{ chatSessionResource: URI }>()); instaService.stub(IChatService, new class extends mock() { @@ -464,10 +467,12 @@ suite('LayoutController', () => { const activeSession = observableValue('active', undefined); instaService.stub(ISessionsManagementService, new class extends mock() { override activeSession = activeSession; - override readonly visibleSessions = constObservable([]); override readonly onDidChangeSessions = Event.None; override getSessions() { return []; } }); + instaService.stub(ISessionsViewService, new class extends mock() { + override readonly visibleSessions = constObservable([]); + }); instaService.stub(IChatService, new class extends mock() { override readonly onDidSubmitRequest = Event.None; }); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 714eb66be4655..b079e2feb2bdb 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -6,7 +6,7 @@ import { disposableTimeout, raceTimeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; -import { structuralEquals } from '../../../../../base/common/equals.js'; +import { arrayEquals, structuralEquals } from '../../../../../base/common/equals.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, IReference, MutableDisposable } from '../../../../../base/common/lifecycle.js'; @@ -581,6 +581,26 @@ function modeEquals( return a.id === b.id && a.kind === b.kind; } +function customizationsChanged(previous: SessionState, state: SessionState): boolean { + if (previous.customizations !== state.customizations) { + return true; + } + const previousActiveCustomizations = previous.activeClient?.customizations; + const currentActiveCustomizations = state.activeClient?.customizations; + if (previousActiveCustomizations === currentActiveCustomizations) { + return false; + } + if (!previousActiveCustomizations || !currentActiveCustomizations) { + return true; + } + return arrayEquals(previousActiveCustomizations, currentActiveCustomizations, (a, b) => { + if (a.nonce !== undefined && a.nonce === b.nonce) { + return true; + } + return a === b; + }); +} + // ============================================================================ // NewSession — bundles the in-flight new-session state // ============================================================================ @@ -2197,7 +2217,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // `SessionState` updates fire for every turn-status / activity / meta // change too — firing on all of them caused excessive picker // recomputes (and a feedback loop with `setAgent`). - if (previous?.customizations !== state.customizations || previous?.activeClient?.customizations !== state.activeClient?.customizations) { + if (!previous || customizationsChanged(previous, state)) { this._onDidChangeCustomAgents.fire(); this._onDidChangeCustomizations.fire(); } @@ -2216,7 +2236,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement private _handleNewSessionStateUpdate(sessionId: string, state: SessionState): void { const previous = this._lastSessionStates.get(sessionId); this._lastSessionStates.set(sessionId, state); - if (previous?.customizations !== state.customizations || previous?.activeClient?.customizations !== state.activeClient?.customizations) { + if (!previous || customizationsChanged(previous, state)) { this._onDidChangeCustomAgents.fire(); this._onDidChangeCustomizations.fire(); } diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts index 644b854135cf5..ac8f022b25ee5 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts @@ -82,7 +82,7 @@ class FakeNonAgentHostProvider { class FakeSessionsManagementService extends mock() { declare readonly _serviceBrand: undefined; override readonly activeSession = observableValue('activeSession', undefined); - override setActive(s: IActiveSession | undefined): void { + override setActiveSession(s: IActiveSession | undefined): void { this.activeSession.set(s, undefined); } } @@ -128,20 +128,20 @@ suite('agentHostSkillButtons - IsAgentHostSession context key', () => { test('is true when active session comes from an agent-host provider', () => { const { contextKeyService, sessions, providers } = setup(); providers.register(new FakeAgentHostProvider('local-agent-host')); - sessions.setActive(makeActiveSession('local-agent-host')); + sessions.setActiveSession(makeActiveSession('local-agent-host')); assert.strictEqual(contextKeyService.getContextKeyValue(IsAgentHostSession.key), true); }); test('is false when active session comes from a non agent-host provider', () => { const { contextKeyService, sessions, providers } = setup(); providers.register(new FakeNonAgentHostProvider('copilot-cloud-agent')); - sessions.setActive(makeActiveSession('copilot-cloud-agent')); + sessions.setActiveSession(makeActiveSession('copilot-cloud-agent')); assert.strictEqual(contextKeyService.getContextKeyValue(IsAgentHostSession.key), false); }); test('is false when active session references an unknown provider', () => { const { contextKeyService, sessions } = setup(); - sessions.setActive(makeActiveSession('no-such-provider')); + sessions.setActiveSession(makeActiveSession('no-such-provider')); assert.strictEqual(contextKeyService.getContextKeyValue(IsAgentHostSession.key), false); }); @@ -150,13 +150,13 @@ suite('agentHostSkillButtons - IsAgentHostSession context key', () => { providers.register(new FakeAgentHostProvider('local-agent-host')); providers.register(new FakeNonAgentHostProvider('copilot-cloud-agent')); - sessions.setActive(makeActiveSession('local-agent-host')); + sessions.setActiveSession(makeActiveSession('local-agent-host')); assert.strictEqual(contextKeyService.getContextKeyValue(IsAgentHostSession.key), true); - sessions.setActive(makeActiveSession('copilot-cloud-agent')); + sessions.setActiveSession(makeActiveSession('copilot-cloud-agent')); assert.strictEqual(contextKeyService.getContextKeyValue(IsAgentHostSession.key), false); - sessions.setActive(undefined); + sessions.setActiveSession(undefined); assert.strictEqual(contextKeyService.getContextKeyValue(IsAgentHostSession.key), false); }); }); diff --git a/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessions.contribution.ts b/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessions.contribution.ts index a72d3d23c0c13..e0fd05118e354 100644 --- a/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessions.contribution.ts +++ b/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessions.contribution.ts @@ -12,6 +12,7 @@ import { Registry } from '../../../../../platform/registry/common/platform.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { localize } from '../../../../../nls.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsViewService } from '../../../../browser/sessionsViewService.js'; import { ForkConversationAction } from '../../../../../workbench/contrib/chat/browser/actions/chatForkActions.js'; import { registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -59,6 +60,7 @@ registerAction2(class extends ForkConversationAction { protected override _openForkedSession(instantiationService: IInstantiationService, parentSessionResource: URI, forkedSessionResource: URI): Promise { return instantiationService.invokeFunction(async accessor => { const sessionsManagementService = accessor.get(ISessionsManagementService); + const sessionsViewService = accessor.get(ISessionsViewService); const logService = accessor.get(ILogService); const parentSession = sessionsManagementService.getSession(parentSessionResource); @@ -87,7 +89,7 @@ registerAction2(class extends ForkConversationAction { return; } } - await sessionsManagementService.openSession(forkedSessionResource); + await sessionsViewService.openSession(forkedSessionResource); }); } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts index 639d00ff27ebe..8d4fd83f80eec 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts @@ -35,6 +35,7 @@ import { SessionsCategories } from '../../../../common/categories.js'; import { SessionWorkspacePickerGroupContext } from '../../../../common/contextkeys.js'; import { Menus } from '../../../../browser/menus.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsViewService } from '../../../../browser/sessionsViewService.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../../common/agentHostSessionsProvider.js'; import { SESSION_WORKSPACE_GROUP_REMOTE } from '../../../../services/sessions/common/session.js'; @@ -578,6 +579,7 @@ async function promptForRemoteFolder( ): Promise { const sessionsProvidersService = accessor.get(ISessionsProvidersService); const sessionsManagementService = accessor.get(ISessionsManagementService); + const sessionsViewService = accessor.get(ISessionsViewService); const sessionsPartService = accessor.get(ISessionsPartService); // The provider is created synchronously during addManagedConnection's @@ -602,7 +604,7 @@ async function promptForRemoteFolder( return; } - sessionsManagementService.openNewSessionView(); + sessionsViewService.openNewSession(); sessionsPartService.getSessionView(sessionsManagementService.activeSession.get()?.sessionId)?.selectWorkspace(folderUri); } @@ -925,6 +927,7 @@ async function promptForTunnelFolder( ): Promise { const sessionsProvidersService = accessor.get(ISessionsProvidersService); const sessionsManagementService = accessor.get(ISessionsManagementService); + const sessionsViewService = accessor.get(ISessionsViewService); const sessionsPartService = accessor.get(ISessionsPartService); const tunnelAddress = `${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`; @@ -951,7 +954,7 @@ async function promptForTunnelFolder( return; } - sessionsManagementService.openNewSessionView(); + sessionsViewService.openNewSession(); sessionsPartService.getSessionView(sessionsManagementService.activeSession.get()?.sessionId)?.selectWorkspace(folderUri, provider.id); } @@ -1111,6 +1114,7 @@ async function promptForWSLFolder( ): Promise { const sessionsProvidersService = accessor.get(ISessionsProvidersService); const sessionsManagementService = accessor.get(ISessionsManagementService); + const sessionsViewService = accessor.get(ISessionsViewService); const sessionsPartService = accessor.get(ISessionsPartService); const wslAddress = `wsl:${distro}`; @@ -1133,7 +1137,7 @@ async function promptForWSLFolder( return; } - sessionsManagementService.openNewSessionView(); + sessionsViewService.openNewSession(); sessionsPartService.getSessionView(sessionsManagementService.activeSession.get()?.sessionId)?.selectWorkspace(folderUri, provider.id); } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index cd0995df55568..d40a607292170 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -20,6 +20,7 @@ import { SessionsCategories } from '../../../common/categories.js'; import { CanGoBackContext, CanGoForwardContext, ChatSessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../common/agentHostSessionsProvider.js'; import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsViewService } from '../../../browser/sessionsViewService.js'; import { ISession } from '../../../services/sessions/common/session.js'; import { ISessionsPartService } from '../../../browser/parts/sessionsPartService.js'; import { ISessionsListModelService } from '../../../services/sessions/browser/sessionsListModelService.js'; @@ -46,11 +47,12 @@ registerAction2(class ShowSessionsPickerAction extends Action2 { override async run(accessor: ServicesAccessor) { const sessionsManagementService = accessor.get(ISessionsManagementService); + const sessionsViewService = accessor.get(ISessionsViewService); const quickInputService = accessor.get(IQuickInputService); const sessionsPartService = accessor.get(ISessionsPartService); const sessionsListModelService = accessor.get(ISessionsListModelService); - const { recent, other } = sessionsManagementService.getRecentlyOpenedSessions(); + const { recent, other } = sessionsViewService.getRecentlyOpenedSessions(); const recentSessions = recent.filter(s => !s.isArchived.get()); const otherSessions = other.filter(s => !s.isArchived.get()); @@ -146,7 +148,7 @@ registerAction2(class ShowSessionsPickerAction extends Action2 { const openSelected = (selected: ISessionPickItem, inBackground: boolean, toSide: boolean): void => { if (!selected.session) { - sessionsManagementService.openNewSessionView({ inheritWorkspaceFromActiveSession: true }); + sessionsViewService.openNewSession(); sessionsPartService.focusSession(sessionsManagementService.activeSession.get()); return; } @@ -156,9 +158,9 @@ registerAction2(class ShowSessionsPickerAction extends Action2 { // normal open when there is no active session to anchor against or the // session is already the active one. if (toSide && activeSessionId !== undefined && selected.session.sessionId !== activeSessionId) { - sessionsManagementService.insertAt(selected.session, activeSessionId, 'right', !inBackground); + sessionsViewService.insertAt(selected.session, activeSessionId, 'right', !inBackground); } else { - sessionsManagementService.openSession(selected.session.resource, { preserveFocus: inBackground }); + sessionsViewService.openSession(selected.session.resource, { preserveFocus: inBackground }); } }; @@ -217,8 +219,7 @@ registerAction2(class GoBackAction extends Action2 { } override async run(accessor: ServicesAccessor): Promise { - const sessionsManagementService = accessor.get(ISessionsManagementService); - await sessionsManagementService.openPreviousSession(); + await accessor.get(ISessionsViewService).openPreviousSession(); } }); @@ -259,8 +260,7 @@ registerAction2(class GoForwardAction extends Action2 { } override async run(accessor: ServicesAccessor): Promise { - const sessionsManagementService = accessor.get(ISessionsManagementService); - await sessionsManagementService.openNextSession(); + await accessor.get(ISessionsViewService).openNextSession(); } }); @@ -311,16 +311,16 @@ for (let index = 0; index < 9; index++) { } override async run(accessor: ServicesAccessor): Promise { - const sessionsManagementService = accessor.get(ISessionsManagementService); + const sessionsViewService = accessor.get(ISessionsViewService); const sessionsPartService = accessor.get(ISessionsPartService); - const visible = sessionsManagementService.visibleSessions.get(); + const visible = sessionsViewService.visibleSessions.get(); if (index >= visible.length) { return; } const session = visible[index]; - sessionsManagementService.setActive(session); + sessionsViewService.setActive(session); sessionsPartService.focusSession(session); } }); @@ -346,7 +346,7 @@ registerAction2(class CloseAllSessionsAction extends Action2 { } override async run(accessor: ServicesAccessor): Promise { - accessor.get(ISessionsManagementService).closeAllSessions(); + accessor.get(ISessionsViewService).closeAllSessions(); } }); @@ -370,8 +370,9 @@ registerAction2(class AddChatToSessionBarAction extends Action2 { return; } const sessionsManagementService = accessor.get(ISessionsManagementService); + const sessionsViewService = accessor.get(ISessionsViewService); const sessionsPartService = accessor.get(ISessionsPartService); - await sessionsManagementService.openNewChatInSession(session); + await sessionsViewService.openNewChatInSession(session); sessionsPartService.focusSession(sessionsManagementService.activeSession.get()); } }); @@ -400,7 +401,7 @@ registerAction2(class TogglePinSessionAction extends Action2 { if (!session) { return; } - accessor.get(ISessionsManagementService).toggleSessionStickiness(session); + accessor.get(ISessionsViewService).toggleSessionStickiness(session); } }); @@ -462,9 +463,10 @@ registerAction2(class CloseSessionAction extends Action2 { override async run(accessor: ServicesAccessor, session: IActiveSession | undefined): Promise { const sessionsManagementService = accessor.get(ISessionsManagementService); + const sessionsViewService = accessor.get(ISessionsViewService); const sessionsPartService = accessor.get(ISessionsPartService); - sessionsManagementService.closeSession(session); + sessionsViewService.closeSession(session); sessionsPartService.focusSession(sessionsManagementService.activeSession.get()); } }); @@ -491,6 +493,6 @@ registerAction2(class ToggleMaximizeSessionViewAction extends Action2 { override async run(accessor: ServicesAccessor, session: IActiveSession | undefined): Promise { accessor.get(ISessionsPartService).toggleMaximizeSession(session); - accessor.get(ISessionsManagementService).setActive(session); + accessor.get(ISessionsViewService).setActive(session); } }); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts index d72087fe9252e..bb2132b7fe322 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTelemetry.contribution.ts @@ -20,6 +20,7 @@ import { IAgentFeedbackAddedEvent, IAgentFeedbackConvertedEvent, IAgentFeedbackR import { ISessionsTasksService } from '../../chat/browser/sessionsTasksService.js'; import { IChat, ISession, ISessionWorkspace, SessionStatus } from '../../../services/sessions/common/session.js'; import { ISendRequestSentEvent, ISessionsChangeEvent, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsViewService } from '../../../browser/sessionsViewService.js'; import { ISendRequestOptions, ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsPartService } from '../../../browser/parts/sessionsPartService.js'; @@ -48,6 +49,7 @@ export class SessionsTelemetryContribution extends Disposable implements IWorkbe constructor( @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @ISessionsViewService private readonly _sessionsViewService: ISessionsViewService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, @IStorageService private readonly _storageService: IStorageService, @@ -84,7 +86,7 @@ export class SessionsTelemetryContribution extends Disposable implements IWorkbe this._register(this._sessionsManagementService.onDidDeleteSession(session => this._logSessionDeleted(session))); this._register(this._sessionsManagementService.onDidDeleteChat(session => this._logChatDeleted(session))); this._register(this._sessionsManagementService.onDidRenameChat(session => this._logChatRenamed(session))); - this._register(this._sessionsManagementService.onDidToggleSessionStickiness(e => this._logSessionStickinessToggled(e.session, e.sticky))); + this._register(this._sessionsViewService.onDidToggleSessionStickiness(e => this._logSessionStickinessToggled(e.session, e.sticky))); this._register(sessionsPartService.onDidToggleMaximizeSession(e => this._logSessionMaximizeToggled(e.session, e.maximized))); this._register(this._sessionsManagementService.onDidChangeSessions(e => this._onDidChangeSessions(e))); @@ -193,7 +195,7 @@ export class SessionsTelemetryContribution extends Disposable implements IWorkbe } const allSessions = this._sessionsManagementService.getSessions(); - const visibleSessionsCount = this._sessionsManagementService.visibleSessions.get().filter(s => s !== undefined).length; + const visibleSessionsCount = this._sessionsViewService.visibleSessions.get().filter(s => s !== undefined).length; // Snapshot all synchronous fields now so the event reflects the state at // the time of the send, not when the async file-count fetch resolves. const workspace = session.workspace.get(); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 79ba5b4f38f5a..21067629e93ef 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -43,6 +43,7 @@ import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { HoverStyle } from '../../../../../base/browser/ui/hover/hover.js'; import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; import { ISessionsManagementService, IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsViewService } from '../../../../browser/sessionsViewService.js'; import { IAgentSessionsService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { ISessionsListModelService } from '../../../../services/sessions/browser/sessionsListModelService.js'; import { IAgentHostFilterService } from '../../../../services/agentHostFilter/common/agentHostFilter.js'; @@ -861,6 +862,7 @@ export class SessionsList extends Disposable implements ISessionsList { container: HTMLElement, private readonly options: ISessionsListControlOptions, @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @ISessionsViewService private readonly _sessionsViewService: ISessionsViewService, @ISessionsListModelService private readonly _sessionsListModelService: ISessionsListModelService, @IAgentHostFilterService private readonly _agentHostFilterService: IAgentHostFilterService, @IInstantiationService instantiationService: IInstantiationService, @@ -898,7 +900,7 @@ export class SessionsList extends Disposable implements ISessionsList { const agentSessionsService = instantiationService.invokeFunction(accessor => accessor.get(IAgentSessionsService)); const sessionsProvidersService = instantiationService.invokeFunction(accessor => accessor.get(ISessionsProvidersService)); const sessionRenderer = new SessionItemRenderer( - { grouping: this.options.grouping, sorting: this.options.sorting, isPinned: s => this.isSessionPinned(s), isRead: s => this.isSessionRead(s), visibleSessions: this._sessionsManagementService.visibleSessions }, + { grouping: this.options.grouping, sorting: this.options.sorting, isPinned: s => this.isSessionPinned(s), isRead: s => this.isSessionRead(s), visibleSessions: this._sessionsViewService.visibleSessions }, approvalModel, instantiationService, contextKeyService, diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts index d39e839cde669..c77ea69fd7c5d 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts @@ -39,6 +39,7 @@ import { IHostService } from '../../../../../workbench/services/host/browser/hos import { IWorkbenchLayoutService, Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { logSessionsInteraction } from '../../../../common/sessionsTelemetry.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsViewService } from '../../../../browser/sessionsViewService.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; import { Menus } from '../../../../browser/menus.js'; import { MobileSessionFilterChips } from '../../../../browser/parts/mobile/mobileSessionFilterChips.js'; @@ -59,13 +60,13 @@ const SORTING_STORAGE_KEY = 'sessionsViewPane.sorting'; * the session is already the last visible one, this is a no-op aside from * activation. */ -export async function openSessionToTheSide(sessionsManagementService: ISessionsManagementService, session: ISession, options?: { preserveFocus?: boolean }): Promise { - const visible = sessionsManagementService.visibleSessions.get(); +export async function openSessionToTheSide(sessionsViewService: ISessionsViewService, session: ISession, options?: { preserveFocus?: boolean }): Promise { + const visible = sessionsViewService.visibleSessions.get(); const lastVisible = visible[visible.length - 1]; if (lastVisible && lastVisible.sessionId !== session.sessionId) { - sessionsManagementService.insertAt(session, lastVisible.sessionId, 'right'); + sessionsViewService.insertAt(session, lastVisible.sessionId, 'right'); } - await sessionsManagementService.openSession(session.resource, options); + await sessionsViewService.openSession(session.resource, options); } export const SessionsViewFilterSubMenu = new MenuId('SessionsViewPaneFilterSubMenu'); @@ -104,6 +105,7 @@ export class SessionsView extends ViewPane { @IThemeService themeService: IThemeService, @IHoverService hoverService: IHoverService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @ISessionsViewService private readonly sessionsViewService: ISessionsViewService, @IHostService private readonly hostService: IHostService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IStorageService private readonly storageService: IStorageService, @@ -219,11 +221,11 @@ export class SessionsView extends ViewPane { // Alt-click: open the session to the right of the last visible session in the grid. const session = this.sessionsManagementService.getSession(resource); if (session) { - openSessionToTheSide(this.sessionsManagementService, session, { preserveFocus }).then(onOpened).catch(onUnexpectedError); + openSessionToTheSide(this.sessionsViewService, session, { preserveFocus }).then(onOpened).catch(onUnexpectedError); return; } } - this.sessionsManagementService.openSession(resource, { preserveFocus }).then(onOpened).catch(onUnexpectedError); + this.sessionsViewService.openSession(resource, { preserveFocus }).then(onOpened).catch(onUnexpectedError); }, })); this._register(this.onDidChangeBodyVisibility(visible => sessionsControl.setVisible(visible))); @@ -338,7 +340,7 @@ export class SessionsView extends ViewPane { newSessionButton.element.classList.add('agent-sessions-compact-new-button'); this._register(newSessionButton.onDidClick(() => { logSessionsInteraction(this.telemetryService, 'newSession'); - this.sessionsManagementService.openNewSessionView({ inheritWorkspaceFromActiveSession: true }); + this.sessionsViewService.openNewSession(); this.sessionsPartService.focusSession(this.sessionsManagementService.activeSession.get()); })); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index 65f2df83a57dd..f1c58ac80e9b1 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -31,6 +31,7 @@ import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/ac import { ActiveSessionContextKeys } from '../../../changes/common/changes.js'; import { hasActiveSessionFailedCIChecks } from '../../../changes/browser/checksActions.js'; import { ISessionsPartService } from '../../../../browser/parts/sessionsPartService.js'; +import { ISessionsViewService } from '../../../../browser/sessionsViewService.js'; // Constants @@ -58,8 +59,8 @@ registerAction2(class CloseSessionAction extends Action2 { }); } override async run(accessor: ServicesAccessor) { - const sessionsService = accessor.get(ISessionsManagementService); - sessionsService.openNewSessionView(); + const sessionsViewService = accessor.get(ISessionsViewService); + sessionsViewService.openNewSession(); } }); @@ -95,7 +96,7 @@ const openSessionAtIndex = (accessor: ServicesAccessor, sessionIndex: unknown): return; } const viewsService = accessor.get(IViewsService); - const sessionsManagementService = accessor.get(ISessionsManagementService); + const sessionsViewService = accessor.get(ISessionsViewService); const view = viewsService.getViewWithId(SessionsViewId); const visible = view?.sessionsControl?.getVisibleSessions() ?? []; if (visible.length === 0) { @@ -108,7 +109,7 @@ const openSessionAtIndex = (accessor: ServicesAccessor, sessionIndex: unknown): if (!target) { return; } - sessionsManagementService.openSession(target.resource); + sessionsViewService.openSession(target.resource); }; CommandsRegistry.registerCommand({ @@ -349,11 +350,12 @@ registerAction2(class NewSessionForWorkspaceAction extends Action2 { if (!context || !context.sessions || context.sessions.length === 0) { return; } + const sessionsViewService = accessor.get(ISessionsViewService); const sessionsManagementService = accessor.get(ISessionsManagementService); const sessionsPartService = accessor.get(ISessionsPartService); const commandService = accessor.get(ICommandService); - sessionsManagementService.openNewSessionView(); + sessionsViewService.openNewSession(); const session = context.sessions[0]; const workspace = session.workspace.get(); @@ -656,6 +658,11 @@ registerAction2(class RenameSessionAction extends Action2 { group: '1_edit', order: 1, when: ContextKeyExpr.regex(ChatSessionProviderIdContext.key, ANY_AGENT_HOST_PROVIDER_RE), + }, { + id: Menus.SessionHeaderContext, + group: '2_edit', + order: 1, + when: ContextKeyExpr.regex(ChatSessionProviderIdContext.key, ANY_AGENT_HOST_PROVIDER_RE), }] }); } @@ -775,22 +782,22 @@ registerAction2(class OpenSessionToTheSideAction extends Action2 { return; } const sessions = Array.isArray(context) ? context : [context]; - const sessionsManagementService = accessor.get(ISessionsManagementService); + const sessionsViewService = accessor.get(ISessionsViewService); const sessionsPartService = accessor.get(ISessionsPartService); for (let i = 0; i < sessions.length - 1; i++) { const session = sessions[i]; - const visible = sessionsManagementService.visibleSessions.get(); + const visible = sessionsViewService.visibleSessions.get(); const lastVisible = visible[visible.length - 1]; if (lastVisible && lastVisible.sessionId !== session.sessionId) { - sessionsManagementService.insertAt(session, lastVisible.sessionId, 'right'); + sessionsViewService.insertAt(session, lastVisible.sessionId, 'right'); } } const lastRequested = sessions[sessions.length - 1]; - await openSessionToTheSide(sessionsManagementService, lastRequested); + await openSessionToTheSide(sessionsViewService, lastRequested); - const visibleAfterOpen = sessionsManagementService.visibleSessions.get(); + const visibleAfterOpen = sessionsViewService.visibleSessions.get(); const opened = visibleAfterOpen.find(s => s?.sessionId === lastRequested.sessionId); if (opened) { sessionsPartService.focusSession(opened); diff --git a/src/vs/sessions/services/sessions/browser/sessionNavigation.ts b/src/vs/sessions/services/sessions/browser/sessionNavigation.ts index e5e1f78154237..3c8ffb7be0540 100644 --- a/src/vs/sessions/services/sessions/browser/sessionNavigation.ts +++ b/src/vs/sessions/services/sessions/browser/sessionNavigation.ts @@ -9,7 +9,7 @@ import { URI } from '../../../../base/common/uri.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { CanGoBackContext, CanGoForwardContext } from '../../../common/contextkeys.js'; -import { SessionStatus } from '../common/session.js'; +import { ISession, SessionStatus } from '../common/session.js'; import { ISessionsChangeEvent, ISessionsManagementService } from '../common/sessionsManagement.js'; import { IRecencyEntry, SessionsRecencyHistory } from './sessionsRecencyHistory.js'; @@ -17,6 +17,16 @@ function entryKey(sessionResource: URI, chatResource: URI | undefined): string { return `${sessionResource.toString()}::${chatResource?.toString() ?? ''}`; } +/** + * The subset of opening behaviour {@link SessionsNavigation} drives. Implemented + * by the view service, passed in to avoid the navigation (a `services` module) + * depending on the core view service. + */ +export interface ISessionOpener { + openSession(sessionResource: URI, options?: { preserveFocus?: boolean }): Promise; + openChat(session: ISession, chatResource: URI): Promise; +} + /** * Provides Back/Forward navigation over the shared session recency history * ({@link SessionsRecencyHistory}). Created and owned by @@ -67,6 +77,7 @@ export class SessionsNavigation extends Disposable { }); constructor( + private readonly _opener: ISessionOpener, private readonly _sessionsManagementService: ISessionsManagementService, private readonly _recency: SessionsRecencyHistory, contextKeyService: IContextKeyService, @@ -195,12 +206,12 @@ export class SessionsNavigation extends Disposable { if (entry.chatResource) { const chatExists = session.chats.get().some(c => c.resource.toString() === entry.chatResource!.toString()); if (chatExists) { - await this._sessionsManagementService.openChat(session, entry.chatResource); + await this._opener.openChat(session, entry.chatResource); } else { - await this._sessionsManagementService.openSession(entry.sessionResource); + await this._opener.openSession(entry.sessionResource); } } else { - await this._sessionsManagementService.openSession(entry.sessionResource); + await this._opener.openSession(entry.sessionResource); } } else { // Session no longer exists, remove its entries from history diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 82f71d087f4b4..17ac11d53be1d 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -4,63 +4,21 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from '../../../../base/common/event.js'; -import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; -import { disposableTimeout } from '../../../../base/common/async.js'; -import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { ResourceMap } from '../../../../base/common/map.js'; -import { IObservable, autorun } from '../../../../base/common/observable.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; +import { IObservable, autorun, observableValue } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { ChatAgentLocation } from '../../../../workbench/contrib/chat/common/constants.js'; import { IChatWidgetHistoryService } from '../../../../workbench/contrib/chat/common/widget/chatWidgetHistoryService.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { ActiveSessionProviderIdContext, ActiveSessionTypeContext, IsActiveSessionArchivedContext, ActiveSessionWorkspaceIsVirtualContext, IsNewChatSessionContext } from '../../../common/contextkeys.js'; -import { ActiveSessionSupportsMultiChatContext, IActiveSession, ICreateNewSessionOptions, IProviderSessionType, IRecentlyOpenedSessions, ISendRequestOptions, ISendRequestSentEvent, ISessionsChangeEvent, ISessionsManagementService, IToggleSessionStickinessEvent } from '../common/sessionsManagement.js'; +import { ActiveSessionSupportsMultiChatContext, IActiveSession, ICreateNewSessionOptions, IProviderSessionType, ISendRequestOptions, ISendRequestSentEvent, ISessionsChangeEvent, ISessionsManagementService } from '../common/sessionsManagement.js'; import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from './sessionsProvidersService.js'; import { ISessionChangeEvent, ISessionsProvider } from '../common/sessionsProvider.js'; import { IChat, ISession, ISessionWorkspace, SessionStatus, ISessionType } from '../common/session.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { SessionsNavigation } from './sessionNavigation.js'; -import { SessionsRecencyHistory } from './sessionsRecencyHistory.js'; -import { VisibleSessions } from './visibleSessions.js'; - -const ACTIVE_SESSION_STATES_KEY = 'agentSessions.activeSessionStates'; - -/** - * Upper bound on how long restore waits for a persisted session to resurface - * via its provider. Generous (providers may load after auth settles) but finite - * so a session that is gone for good cannot keep restore — and its provider - * listeners — alive indefinitely. - */ -const RESTORE_SESSION_WAIT_TIMEOUT = 30_000; - -/** Maximum number of recently opened sessions reported by {@link SessionsManagementService.getRecentlyOpenedSessions}. */ -const MAX_RECENTLY_OPENED_SESSIONS = 10; - -/** - * Persisted state for a session. - * Extend this interface to store additional per-session state that should be - * remembered across restarts. - */ -interface ISessionState { - /** The resource URI of the session. */ - sessionResource: string; - /** The resource URI of the last active chat within the session. */ - activeChatResource?: string; - /** Whether this session was the active session at the time of save. */ - isActive?: boolean; - /** - * Position (left-to-right) of the session in the grid at save time, when - * the session was visible. `undefined` when the session was not visible. - */ - visibleOrder?: number; - /** Whether the session was pinned (sticky) in the grid at save time. */ - isSticky?: boolean; -} export class SessionsManagementService extends Disposable implements ISessionsManagementService { @@ -87,48 +45,29 @@ export class SessionsManagementService extends Disposable implements ISessionsMa private readonly _onDidRenameChat = this._register(new Emitter()); readonly onDidRenameChat: Event = this._onDidRenameChat.event; - private readonly _onDidToggleSessionStickiness = this._register(new Emitter()); - readonly onDidToggleSessionStickiness: Event = this._onDidToggleSessionStickiness.event; - private readonly _onDidChangeSessionTypes = this._register(new Emitter()); readonly onDidChangeSessionTypes: Event = this._onDidChangeSessionTypes.event; + private readonly _onDidReplaceSession = this._register(new Emitter<{ readonly from: ISession; readonly to: ISession }>()); + readonly onDidReplaceSession: Event<{ readonly from: ISession; readonly to: ISession }> = this._onDidReplaceSession.event; + private _sessionTypes: readonly ISessionType[] = []; - /** Owns the active/sticky/transient visibility model and the {@link ActiveSession} wrappers. */ - private readonly _visibility: VisibleSessions; - readonly activeSession: IObservable; - readonly visibleSessions: IObservable; + /** The canonical active session, mirrored from the visible active slot by the view service. */ + private readonly _activeSession = observableValue(this, undefined); + readonly activeSession: IObservable = this._activeSession; + + /** Tracks the in-progress new session (composed but not yet sent). */ + private readonly _newSession = observableValue(this, undefined); + readonly newSession: IObservable = this._newSession; - /** Tracks the pending new session so it can be restored by {@link openNewSessionView}. */ - private _pendingNewSession: ISession | undefined; private readonly _isNewChatSessionContext: IContextKey; private readonly _activeSessionProviderId: IContextKey; private readonly _activeSessionType: IContextKey; private readonly _activeSessionWorkspaceIsVirtual: IContextKey; private readonly _isActiveSessionArchived: IContextKey; private readonly _supportsMultiChat: IContextKey; - /** Cancelled on every navigation action so in-flight async opens bail out. */ - private readonly _openSessionCts = this._register(new MutableDisposable()); - /** - * Cancellation for the in-flight {@link restoreVisibleSessions}. Kept - * separate from {@link _openSessionCts} so that additive new-session - * operations (the new-chat composer eagerly creating a draft on startup) - * do not abort restoring the previously visible grid. Only an explicit - * navigation to a specific session cancels a restore. - */ - private readonly _restoreCts = this._register(new MutableDisposable()); - private readonly _onDidOpenNewSessionView = this._register(new Emitter()); private readonly _providerListeners = this._register(new DisposableMap()); - private readonly _sessionStates: ResourceMap; - private readonly _navigation: SessionsNavigation; - /** - * The single source of truth for session recency (most-recently-opened - * first), persisted across restarts. Both the recent-sessions picker (via - * {@link getRecentlyOpenedSessions}) and {@link SessionsNavigation} build on - * top of it. - */ - private readonly _recencyHistory: SessionsRecencyHistory; /** * Chat resources for which this service has just kicked off a @@ -140,12 +79,10 @@ export class SessionsManagementService extends Disposable implements ISessionsMa private readonly _pendingSendChatResources = new Set(); constructor( - @IStorageService private readonly storageService: IStorageService, @ILogService private readonly logService: ILogService, @IContextKeyService contextKeyService: IContextKeyService, @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, - @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @IChatService private readonly chatService: IChatService, @IChatWidgetHistoryService private readonly chatWidgetHistoryService: IChatWidgetHistoryService, ) { @@ -160,22 +97,6 @@ export class SessionsManagementService extends Disposable implements ISessionsMa this._isActiveSessionArchived = IsActiveSessionArchivedContext.bindTo(contextKeyService); this._supportsMultiChat = ActiveSessionSupportsMultiChatContext.bindTo(contextKeyService); - // Load persisted state - this._sessionStates = this._loadSessionStates(); - - // Visibility model — owns wrappers, active/sticky/transient state, and - // observables exposed to the UI. - this._visibility = this._register(new VisibleSessions( - session => this._restoreInitialChat(session), - this.uriIdentityService, - this.agentSessionsService, - )); - this.activeSession = this._visibility.activeSession; - this.visibleSessions = this._visibility.visibleSessions; - - // Save on shutdown - this._register(this.storageService.onWillSaveState(() => this._saveSessionStates())); - // Subscribe to provider changes for session type updates this._register(this.sessionsProvidersService.onDidChangeProviders(e => { this._onProvidersChanged(e); @@ -184,26 +105,10 @@ export class SessionsManagementService extends Disposable implements ISessionsMa this._subscribeToProviders(this.sessionsProvidersService.getProviders()); this._sessionTypes = this._collectSessionTypes(); - // Session recency history — the single source of truth for "recently - // opened" ordering, shared by the picker and navigation. - this._recencyHistory = this._register(new SessionsRecencyHistory( - this.storageService, - this.logService, - )); - - // Session navigation history (Back/Forward) builds on the recency history. - this._navigation = this._register(new SessionsNavigation( - this, - this._recencyHistory, - contextKeyService, - this.logService, - )); - this._register(this.onDidChangeSessions(e => this._navigation.onDidRemoveSessions(e))); - this._register(this.onDidDeleteSession(session => this._recencyHistory.remove(entry => entry.sessionResource.toString() === session.resource.toString()))); - this._register(autorun(reader => { - const activeSession = this._visibility.activeSession.read(reader); - this._handleActiveSessionContextKeys(activeSession); + const activeSession = this._activeSession.read(reader); + const newSession = this._newSession.read(reader); + this._handleActiveSessionContextKeys(activeSession, newSession); if (activeSession) { reader.store.add(this._activeSessionListeners(activeSession)); } @@ -236,13 +141,13 @@ export class SessionsManagementService extends Disposable implements ISessionsMa })); } - private _handleActiveSessionContextKeys(session: IActiveSession | undefined): void { + private _handleActiveSessionContextKeys(session: IActiveSession | undefined, newSession: ISession | undefined): void { // Update context keys from session data // IsNewChatSessionContext is true when no active session exists, OR when the - // active session is still pending (created but not yet sent for the first time). - // Scoping to the active session avoids flipping into "new chat" mode while - // viewing a different established session. - this._isNewChatSessionContext.set(session === undefined || session.sessionId === this._pendingNewSession?.sessionId); + // active session is still the in-progress new session (created but not yet + // sent for the first time). Scoping to the active session avoids flipping + // into "new chat" mode while viewing a different established session. + this._isNewChatSessionContext.set(session === undefined || session.sessionId === newSession?.sessionId); this._activeSessionProviderId.set(session?.providerId ?? ''); this._activeSessionType.set(session?.sessionType ?? ''); this._activeSessionWorkspaceIsVirtual.set(session?.workspace.get()?.isVirtualWorkspace ?? true); @@ -254,14 +159,8 @@ export class SessionsManagementService extends Disposable implements ISessionsMa const disposables = new DisposableStore(); // Track archived state changes for the active session - let wasArchived = activeSession.isArchived.get(); disposables.add(autorun(reader => { - const isArchived = activeSession.isArchived.read(reader); - this._isActiveSessionArchived.set(isArchived); - if (isArchived && !wasArchived) { - this.openNewSessionView(); - } - wasArchived = isArchived; + this._isActiveSessionArchived.set(activeSession.isArchived.read(reader)); })); // Track workspace changes so the virtual-workspace context key stays in sync @@ -270,36 +169,6 @@ export class SessionsManagementService extends Disposable implements ISessionsMa this._activeSessionWorkspaceIsVirtual.set(workspace?.isVirtualWorkspace ?? true); })); - // Track chat list changes — if the active chat is removed, fall back - if (activeSession.status.get() !== SessionStatus.Untitled) { - disposables.add(autorun(reader => { - const chats = activeSession.chats.read(reader); - const activeChat = activeSession.activeChat.read(reader); - if (activeChat && !chats.some(c => this.uriIdentityService.extUri.isEqual(c.resource, activeChat.resource))) { - const fallback = chats[chats.length - 1] ?? activeSession.mainChat.read(reader); - if (fallback) { - this.openChat(activeSession, fallback.resource); - } - } - })); - } - - // Track active chat changes to persist per-session state. The visible / - // active / sticky flags are snapshotted from the live grid at save time - // (see `_snapshotVisibleSessionStates`); here we only remember the last active - // chat so reopening the session restores its selected chat. - disposables.add(autorun(reader => { - const chat = activeSession.activeChat.read(reader); - if (chat && chat.status.read(undefined) !== SessionStatus.Untitled) { - const existing = this._sessionStates.get(activeSession.resource); - this._sessionStates.set(activeSession.resource, { - ...existing, - sessionResource: activeSession.resource.toString(), - activeChatResource: chat.resource.toString(), - }); - } - })); - return disposables; } @@ -327,8 +196,9 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } private _handleDidReplaceSession(from: ISession, to: ISession): void { - this._visibility.updateSession(from, to); this.chatWidgetHistoryService.moveHistory(ChatAgentLocation.Chat, from.sessionId, to.sessionId); + // Notify the view service so it can update the visible grid slot. + this._onDidReplaceSession.fire({ from, to }); // Always fire the change event so the SessionsList refreshes even when // the user navigated to a different session while the new one was // being created (which is how duplicate rows appeared in the list). @@ -340,37 +210,18 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } private onDidChangeSessionsFromSessionsProviders(e: ISessionChangeEvent): void { - this._onDidChangeSessions.fire(e); - const currentActive = this._visibility.activeSession.get(); - - // Clear stale pending session if the provider removed it. The provider + // Clear stale new session if the provider removed it. The provider // already disposed it, so just drop the pointer (do not dispose again). - if (e.removed.length && this._pendingNewSession) { - if (e.removed.some(r => r.sessionId === this._pendingNewSession!.sessionId)) { - this._pendingNewSession = undefined; - } - } - - // Clean removed sessions out of the visibility model (drops their grid slot - // and disposes their wrapper). If the active session is among the removed, - // removeMany picks a fallback active session (or clears it when no slot - // remains); drive the open flow below so the fallback is fully opened. if (e.removed.length) { - this._visibility.removeMany(e.removed.map(r => r.sessionId)); - } - - if (!currentActive) { - return; - } - - if (e.removed.length && e.removed.some(r => r.sessionId === currentActive.sessionId)) { - const fallback = this._visibility.activeSession.get(); - if (fallback && this.getSession(fallback.resource)) { - this.openSession(fallback.resource); - } else { - this.openNewSessionView(); + const current = this._newSession.get(); + if (current && e.removed.some(r => r.sessionId === current.sessionId)) { + this._newSession.set(undefined, undefined); } } + + // The view service reacts to this event to drop removed sessions from + // the grid and pick a fallback active session. + this._onDidChangeSessions.fire(e); } getSessions(): ISession[] { @@ -387,37 +238,6 @@ export class SessionsManagementService extends Disposable implements ISessionsMa ); } - getRecentlyOpenedSessions(): IRecentlyOpenedSessions { - const seen = new Set(); - const recent: ISession[] = []; - - // Sessions in recency order (most-recently-opened first), deduplicated by - // session so a session with multiple opened chats appears only once and - // capped at the most recent {@link MAX_RECENTLY_OPENED_SESSIONS}. - for (const entry of this._recencyHistory.entries) { - if (recent.length >= MAX_RECENTLY_OPENED_SESSIONS) { - break; - } - const key = entry.sessionResource.toString(); - if (seen.has(key)) { - continue; - } - seen.add(key); - const session = this.getSession(entry.sessionResource); - if (session) { - recent.push(session); - } - } - - // Sessions that have not been included in the recently opened group, - // sorted by most recently updated first. - const other = this.getSessions() - .filter(s => !seen.has(s.resource.toString())) - .sort((a, b) => b.updatedAt.get().getTime() - a.updatedAt.get().getTime()); - - return { recent, other }; - } - getAllSessionTypes(): ISessionType[] { return [...this._sessionTypes]; } @@ -480,107 +300,33 @@ export class SessionsManagementService extends Disposable implements ISessionsMa this._onDidChangeSessionTypes.fire(); } - /** - * Cancel any in-flight open-session/restore and return a fresh cancellation token. - */ - private _startOpenSession() { - this._openSessionCts.value?.cancel(); - const cts = new CancellationTokenSource(); - this._openSessionCts.value = cts; - return cts.token; - } - - /** - * Cancel an in-flight {@link restoreVisibleSessions}. Called when the user - * explicitly navigates to a specific session, so restore stops fighting - * the user's choice. Additive new-session operations do NOT call this. - */ - private _cancelRestore(): void { - // `cancel()` (not just `clear()`/dispose) so the in-flight restore's - // token actually fires cancellation and bails out; `MutableDisposable` - // disposes the source without cancelling it. - this._restoreCts.value?.cancel(); - this._restoreCts.clear(); - } - - async openChat(session: ISession, chatUri: URI): Promise { - const t0 = Date.now(); - this._cancelRestore(); - const token = this._startOpenSession(); - this.logService.trace(`[SessionsManagement] openChat start uri=${chatUri.toString()} provider=${session.providerId}`); - this.setActiveSession(session); - if (!await this._waitForSessionToLoad(session, token)) { - this.logService.trace(`[SessionsManagement] openChat cancelled while waiting for session to load uri=${chatUri.toString()}`); + setActiveSession(session: IActiveSession | undefined): void { + const previousSession = this._activeSession.get(); + if (previousSession?.sessionId === session?.sessionId) { return; } - // Find the chat and update active chat - let chat: IChat | undefined; - //let previousChatResource: URI | undefined; - const activeSession = this._visibility.activeSession.get(); - if (activeSession) { - //previousChatResource = activeSession.activeChat.get()?.resource; - chat = activeSession.chats.get().find(c => this.uriIdentityService.extUri.isEqual(c.resource, chatUri)); - if (chat) { - this._visibility.setActiveChat(session, chat); - } - } - - // If the chat is untitled (not yet sent), show the new-chat-in-session view - if (chat && chat.status.get() === SessionStatus.Untitled) { - this.logService.trace(`[SessionsManagement] openChat done total=${Date.now() - t0}ms uri=${chatUri.toString()} path=untitled`); - return; + if (session) { + this.logService.info(`[SessionsManagement] Active session changed: ${session.resource.toString()}`); + } else { + this.logService.trace('[SessionsManagement] Active session cleared'); } - this.logService.trace(`[SessionsManagement] openChat done total=${Date.now() - t0}ms uri=${chatUri.toString()}`); + this._activeSession.set(session, undefined); } - async openSession(sessionResource: URI, options?: { preserveFocus?: boolean }): Promise { - this._cancelRestore(); - const token = this._startOpenSession(); - await this._doOpenSession(sessionResource, token, options); - } - - private async _doOpenSession(sessionResource: URI, token: CancellationToken, options?: { preserveFocus?: boolean }): Promise { - const t0 = Date.now(); - const sessionData = this.getSession(sessionResource); - if (!sessionData) { - this.logService.warn(`[SessionsManagement] openSession: session not found uri=${sessionResource.toString()}`); - throw new Error(`Session with resource ${sessionResource.toString()} not found`); - } - this.logService.trace(`[SessionsManagement] openSession start uri=${sessionResource.toString()} provider=${sessionData.providerId}`); - this.setActiveSession(sessionData); - if (!await this._waitForSessionToLoad(sessionData, token)) { - this.logService.trace(`[SessionsManagement] openSession cancelled while waiting for session to load uri=${sessionResource.toString()}`); + discardNewSession(session?: ISession): void { + const current = this._newSession.get(); + if (!current) { return; } - - this.logService.trace(`[SessionsManagement] openSession done total=${Date.now() - t0}ms uri=${sessionResource.toString()}`); - } - - /** - * Delete the currently pending (composed-but-not-sent) new session. - * - * Providers track new sessions themselves and no longer dispose them - * implicitly when a newer one is created, so abandoning a pending session - * must explicitly dispose it through its own provider to release the - * eagerly-acquired backend session. - * - * This is only for abandonment. When a pending session is graduating (being - * sent) or was already removed by its provider, just clear - * {@link _pendingNewSession} directly so the session is left intact. - */ - private _deletePendingNewSession(): void { - const pending = this._pendingNewSession; - this._pendingNewSession = undefined; - if (pending) { - this._getProvider(pending)?.deleteNewSession(pending.sessionId); + // When a specific session is given, only discard if it is the current + // new session; closing an unrelated session must not drop the draft. + if (session && session.sessionId !== current.sessionId) { + return; } - } - - unsetNewSession(): void { - this._deletePendingNewSession(); - this.setActiveSession(undefined); + this._newSession.set(undefined, undefined); + this._getProvider(current)?.deleteNewSession(current.sessionId); } /** @@ -629,30 +375,39 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } createNewSession(folderUri: URI, options?: ICreateNewSessionOptions): ISession { - this._startOpenSession(); - const { provider, sessionTypeId } = this._resolveProviderForNewSession(folderUri, options); - const previousPending = this._pendingNewSession; + const previousNewSession = this._newSession.get(); const session = provider.createNewSession(folderUri, sessionTypeId); - this._pendingNewSession = session; - this.setActiveSession(session); + this._newSession.set(session, undefined); // Providers no longer dispose the previous new session implicitly, so // dispose the one this composer just replaced. Use its own provider // because switching workspace can switch providers. Done after a // successful create so a throw above leaves the previous one intact. - if (previousPending && previousPending.sessionId !== session.sessionId) { - this._getProvider(previousPending)?.deleteNewSession(previousPending.sessionId); + if (previousNewSession && previousNewSession.sessionId !== session.sessionId) { + this._getProvider(previousNewSession)?.deleteNewSession(previousNewSession.sessionId); } return session; } + async createNewChatInSession(session: ISession): Promise { + const provider = this._getProvider(session); + if (!provider) { + this.logService.warn(`[SessionsManagement] createNewChatInSession: provider '${session.providerId}' not found`); + return undefined; + } + // Reuse an existing untitled chat if one exists, otherwise create a new one. + const existingUntitled = session.chats.get().find(c => c.status.get() === SessionStatus.Untitled); + return existingUntitled ?? await provider.createNewChat(session.sessionId); + } + async sendNewChatRequest(session: ISession, options: ISendRequestOptions): Promise { // The session is graduating into the list (being sent), // so the provider keeps owning it — just drop the pointer, do not delete. - this._pendingNewSession = undefined; - this._isNewChatSessionContext.set(false); + // Clearing the new session recomputes the isNewChatSession context key + // via the active-session autorun. + this._newSession.set(undefined, undefined); const provider = this._getProvider(session); if (!provider) { @@ -660,14 +415,11 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } if (options.background) { - // Restore the new-session view synchronously so the composer stays - // put, then run the send fire-and-forget so the composer can reset - // and reseed immediately while the request commits. If the commit - // fails, the graduating draft is stranded (no longer in - // `_pendingNewSession`), so dispose it through its provider to - // release the eager backend session. Safe no-op if the provider - // already graduated/removed it. - this.openNewSessionView(); + // Run the send fire-and-forget so the composer can reset and reseed + // immediately while the request commits. If the commit fails, the + // graduating draft is stranded (no longer the current new session), + // so dispose it through its provider to release the eager backend + // session. Safe no-op if the provider already graduated/removed it. this._sendNewChatRequestInBackground(provider, session, options).catch(e => { provider.deleteNewSession(session.sessionId); this.logService.error('[SessionsManagement] Failed to send background request:', e); @@ -678,53 +430,27 @@ export class SessionsManagementService extends Disposable implements ISessionsMa // Foreground send: notify listeners that a send is starting. Listeners // (e.g., telemetry) can use this to prewarm caches whose result is // consumed when `onDidSendRequest` fires below. The background path - // fires this from within `_sendNewChatRequestInBackground`. + // fires this from within `_sendNewChatRequestInBackground`. The view + // service observes the will/did send pair to keep the newest chat + // active in the visible slot while the send materialises. this._onWillSendRequest.fire(session); - const setActiveChatToLast = () => { - const activeSession = this._visibility.activeSession.get(); - if (activeSession?.sessionId === session.sessionId && this.uriIdentityService.extUri.isEqual(activeSession.activeChat.get().resource, (session).activeChat?.get().resource)) { - const chats = activeSession.chats.get(); - const lastChat = chats[chats.length - 1]; - if (lastChat) { - this._visibility.setActiveChat(session, lastChat); - } - } - }; - - // Listen for chats changing during the send (subsequent chat appears in the group) - const chatsListener = autorun(reader => { - session.chats.read(reader); - setActiveChatToLast(); - }); + // Ask the provider to create the new chat, then send the request. + const chat = await provider.createNewChat(session.sessionId, options.query); + const chatResourceKey = chat.resource.toString(); + this._pendingSendChatResources.add(chatResourceKey); + let updatedSession: ISession; try { - // Ask the provider to create the new chat; open its widget before sending - const chat = await provider.createNewChat(session.sessionId, options.query); - // Swap in a transient session whose resource is the new chat - // resource, so the grid slot reflects the chat that is about to - // be sent before the provider hands us the final session. - const tmpSession = this._visibility.updateResourceOfSession(session, chat.resource); - - const chatResourceKey = chat.resource.toString(); - this._pendingSendChatResources.add(chatResourceKey); - let updatedSession: ISession; - try { - updatedSession = await provider.sendRequest(session.sessionId, chat.resource, options); - } finally { - this._pendingSendChatResources.delete(chatResourceKey); - } - if (updatedSession.sessionId !== session.sessionId) { - this.logService.info(`[SessionsManagement] sendRequest: active session replaced: ${session.sessionId} -> ${updatedSession.sessionId}`); - this._visibility.updateSession(tmpSession, updatedSession); - setActiveChatToLast(); - } - this._onDidStartSession.fire(updatedSession); - - this._onDidSendRequest.fire({ session: updatedSession, chat, isNewSession: true, isNewChat: true, options }); + updatedSession = await provider.sendRequest(session.sessionId, chat.resource, options); } finally { - chatsListener.dispose(); + this._pendingSendChatResources.delete(chatResourceKey); + } + if (updatedSession.sessionId !== session.sessionId) { + this.logService.info(`[SessionsManagement] sendRequest: active session replaced: ${session.sessionId} -> ${updatedSession.sessionId}`); } + this._onDidStartSession.fire(updatedSession); + this._onDidSendRequest.fire({ session: updatedSession, chat, isNewSession: true, isNewChat: true, options }); } /** @@ -797,18 +523,16 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } async sendRequest(session: ISession, chat: IChat, options: ISendRequestOptions): Promise { - // Sending into an existing session abandons any pending composer, so - // dispose it to release its eager backend session. - this._deletePendingNewSession(); + // Sending into an existing session abandons any in-progress new session, + // so dispose it to release its eager backend session. + this.discardNewSession(); // Notify listeners that a send is starting. Listeners (e.g., telemetry) // can use this to prewarm caches whose result is consumed when - // `onDidSendRequest` fires below. + // `onDidSendRequest` fires below. The view service observes the will/did + // send pair to keep the sent chat active in the visible slot. this._onWillSendRequest.fire(session); - // Keep the sent chat as the active chat - this._visibility.setActiveChat(session, chat); - const provider = this._getProvider(session); if (!provider) { throw new Error(`Sessions provider '${session.providerId}' not found`); @@ -824,466 +548,11 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } if (updatedSession.sessionId !== session.sessionId) { this.logService.info(`[SessionsManagement] sendRequest: active session replaced: ${session.sessionId} -> ${updatedSession.sessionId}`); - this._visibility.updateSession(session, updatedSession); } this._onDidSendRequest.fire({ session: updatedSession, chat, isNewSession: false, isNewChat: true, options }); } - openNewSessionView(options?: { inheritWorkspaceFromActiveSession?: boolean }): void { - const current = this._visibility.activeSession.get(); - // No-op when no session is active (empty new-session placeholder showing). - if (current === undefined) { - return; - } - this._startOpenSession(); - - // When explicitly requested, inherit the workspace of the session being - // switched away from so the new session view opens in the same workspace - // rather than defaulting to the last composed new session's workspace. - // Only inherit from an established session (not the pending new session) - // and only when its workspace differs from any pending new session, so - // an existing in-progress draft for that same workspace is preserved. - if (options?.inheritWorkspaceFromActiveSession && current.sessionId !== this._pendingNewSession?.sessionId) { - const inheritUri = current.workspace.get()?.folders[0]?.root; - const pendingUri = this._pendingNewSession?.workspace.get()?.folders[0]?.root; - if (inheritUri && !this.uriIdentityService.extUri.isEqual(inheritUri, pendingUri)) { - try { - // Creates a fresh pending new session for the inherited - // workspace and makes it active, disposing the previous one. - this.createNewSession(inheritUri); - this._onDidOpenNewSessionView.fire(); - return; - } catch (e) { - this.logService.warn(`[SessionsManagement] openNewSessionView: failed to inherit workspace '${inheritUri.toString()}', falling back to default`, e); - } - } - } - - // Restore the pending new session if one exists, so pickers - // re-derive their state from the still-alive session object. - // Otherwise clear active session (first time / after send). - this.setActiveSession(this._pendingNewSession ?? undefined); - this._onDidOpenNewSessionView.fire(); - } - - async openNewChatInSession(session: ISession): Promise { - this._cancelRestore(); - this._startOpenSession(); - const provider = this._getProvider(session); - if (!provider) { - this.logService.warn(`[SessionsManagement] openNewChatInSession: provider '${session.providerId}' not found`); - return; - } - - // Reuse an existing untitled chat if one exists, otherwise create a new one - const existingUntitled = session.chats.get().find(c => c.status.get() === SessionStatus.Untitled); - const chat = existingUntitled ?? await provider.createNewChat(session.sessionId); - - this.setActiveSession(session); - - // Set the chat as the active chat - this._visibility.setActiveChat(session, chat); - } - - setActive(session: IActiveSession | undefined) { - this.setActiveSession(session); - } - - private setActiveSession(session: ISession | undefined, force?: boolean): void { - const previousSession = this._visibility.activeSession.get(); - if (!force && previousSession?.sessionId === session?.sessionId) { - return; - } - - if (session) { - this.logService.info(`[ActiveSessionService] Active session changed: ${session.resource.toString()}`); - } else { - this.logService.trace('[ActiveSessionService] Active session cleared'); - } - - this._visibility.setActive(session); - } - - toggleSessionStickiness(session: ISession): void { - const sticky = this._visibility.toggleStickiness(session); - this._onDidToggleSessionStickiness.fire({ session, sticky }); - } - - insertAt(session: ISession, targetSessionId: string, side: 'left' | 'right', activate: boolean = true): void { - this._visibility.insertAt(session, targetSessionId, side, activate); - } - - closeSession(session: ISession | undefined): void { - const sessionId = session?.sessionId; - const visible = this._visibility.visibleSessions.get(); - if (!visible.some(s => s?.sessionId === sessionId)) { - return; - } - - // The empty/new-session slot has no sessionId; both it and "no active - // session" are reported by activeSession as undefined. Since we already - // confirmed the slot is present in `visible`, undefined === undefined - // here means the empty slot is active. - const activeSessionId = this._visibility.activeSession.get()?.sessionId; - const wasActive = activeSessionId === sessionId; - - if (sessionId === undefined || this._pendingNewSession?.sessionId === sessionId) { - this._deletePendingNewSession(); - } - - this._visibility.removeMany([sessionId]); - - if (!wasActive) { - return; - } - - // removeMany already picked a fallback active session (or cleared the - // active observable when no slot remains); drive the full open flow. - const fallback = this._visibility.activeSession.get(); - if (fallback === undefined) { - this.openNewSessionView(); - } - } - - closeAllSessions(): void { - const ids = this._visibility.visibleSessions.get() - .filter((s): s is IActiveSession => !!s) - .map(s => s.sessionId); - if (ids.length === 0) { - return; - } - - this._pendingNewSession = undefined; - - // Remove every visible session in a single pass; the visibility model - // clears the active session, which drives the grid back to the - // new-session view via the part's reconciliation. - this._visibility.removeMany(ids); - } - - private _restoreInitialChat(session: ISession): IChat { - const chats = session.chats.get(); - let initialChat = chats[0]; - const sessionState = this._sessionStates.get(session.resource); - if (sessionState?.activeChatResource) { - try { - const lastChatResource = URI.parse(sessionState.activeChatResource); - const found = chats.find(c => this.uriIdentityService.extUri.isEqual(c.resource, lastChatResource)); - if (found) { - initialChat = found; - } - } catch (error) { - this.logService.warn('[ActiveSessionService] Failed to restore active chat from stored session state', error); - } - } - return initialChat; - } - - private async _waitForSessionToLoad(session: ISession, token: CancellationToken): Promise { - if (!session.loading.get()) { - return true; - } - if (token.isCancellationRequested) { - return false; - } - - await new Promise(resolve => { - const disposables = new DisposableStore(); - let resolved = false; - const finish = () => { - if (resolved) { - return; - } - resolved = true; - disposables.dispose(); - resolve(); - }; - - disposables.add(token.onCancellationRequested(finish)); - disposables.add(autorun(reader => { - if (!session.loading.read(reader)) { - finish(); - } - })); - }); - - return !token.isCancellationRequested; - } - - private _loadSessionStates(): ResourceMap { - const map = new ResourceMap(); - const raw = this.storageService.get(ACTIVE_SESSION_STATES_KEY, StorageScope.WORKSPACE); - if (!raw) { - return map; - } - try { - const entries: ISessionState[] = JSON.parse(raw); - for (const entry of entries) { - const uri = URI.parse(entry.sessionResource); - map.set(uri, entry); - } - } catch { - // ignore corrupt data - } - return map; - } - - private _saveSessionStates(): void { - const entries = this._snapshotVisibleSessionStates(); - this.storageService.store(ACTIVE_SESSION_STATES_KEY, JSON.stringify(entries), StorageScope.WORKSPACE, StorageTarget.MACHINE); - } - - private _snapshotVisibleSessionStates(): ISessionState[] { - const activeId = this._visibility.activeSession.get()?.sessionId; - const visible = this._visibility.visibleSessions.get(); - const entries: ISessionState[] = []; - visible.forEach((session, index) => { - if (!session) { - return; - } - - if (session.status.get() === SessionStatus.Untitled) { - this._sessionStates.delete(session.resource); - return; - } - - // Keep the in-memory record up to date so the session's last active - // chat is remembered while reopening it within this window. - const existing = this._sessionStates.get(session.resource); - const state: ISessionState = { - sessionResource: session.resource.toString(), - activeChatResource: session.activeChat.get()?.resource.toString() ?? existing?.activeChatResource, - visibleOrder: index, - isSticky: session.sticky.get(), - isActive: session.sessionId === activeId, - }; - this._sessionStates.set(session.resource, state); - entries.push(state); - }); - return entries; - } - - /** - * The persisted visible sessions, ordered left-to-right by their stored - * grid position. - */ - private _getVisibleSessionStates(): ISessionState[] { - const states: ISessionState[] = []; - for (const [, state] of this._sessionStates) { - if (state.visibleOrder !== undefined) { - states.push(state); - } - } - return states.sort((a, b) => (a.visibleOrder! - b.visibleOrder!)); - } - - /** - * Wait for the session with the given resource to become available via its - * provider, resolving with the session or `undefined` if the token is - * cancelled before it appears. When `timeout` is given, resolves with - * `undefined` after that many milliseconds so a persisted session that never - * resurfaces (e.g. deleted while the window was closed) cannot keep restore - * pending — and its provider listeners alive — indefinitely. - */ - private _waitForSession(sessionResource: URI, token: CancellationToken, timeout?: number): Promise { - const existing = this.getSession(sessionResource); - if (existing) { - return Promise.resolve(existing); - } - return new Promise(resolve => { - const disposables = new DisposableStore(); - let resolved = false; - const finish = (session: ISession | undefined) => { - if (resolved) { - return; - } - resolved = true; - disposables.dispose(); - resolve(session); - }; - - disposables.add(token.onCancellationRequested(() => finish(undefined))); - - const tryFind = () => { - if (token.isCancellationRequested) { - finish(undefined); - return; - } - const session = this.getSession(sessionResource); - if (session) { - finish(session); - } - }; - - // Providers (e.g. the agent host) load their session cache - // asynchronously, so the session may appear via either a provider - // change or a session list change. - disposables.add(this.sessionsProvidersService.onDidChangeProviders(() => tryFind())); - disposables.add(this.onDidChangeSessions(() => tryFind())); - - // Give up after the timeout so the listeners above are not retained - // forever when the session is gone for good. - if (timeout !== undefined) { - disposables.add(disposableTimeout(() => finish(undefined), timeout)); - } - - // In case the session became available between the initial check and - // the listener registration. - tryFind(); - }); - } - - async restoreVisibleSessions(): Promise { - // Ordered list of slots to restore: real sessions plus, optionally, the - // empty (new-session) slot when it was active. - interface IRestoreTarget { - readonly resource: URI | undefined; - readonly isSticky: boolean; - readonly isActive: boolean; - readonly order: number; - } - - const targets: IRestoreTarget[] = this._getVisibleSessionStates().map(state => ({ - resource: URI.parse(state.sessionResource), - isSticky: !!state.isSticky, - isActive: !!state.isActive, - order: state.visibleOrder!, - })); - - if (targets.length === 0) { - targets.push({ resource: undefined, isSticky: false, isActive: true, order: 1 }); - } - - targets.sort((a, b) => a.order - b.order); - - let activeIdx = targets.findIndex(t => t.isActive); - if (activeIdx < 0) { - activeIdx = 0; - } - - // Use a dedicated cancellation token (not the shared open-session one) - // so that a new-session draft created during restore (e.g. by the - // new-chat composer on startup) does not abort restoring the grid. The - // token is cancelled only when the user explicitly opens a session. - const cts = new CancellationTokenSource(); - this._restoreCts.value = cts; - const token = cts.token; - - // Sessions resolved so far, indexed by their position in `targets`. - // `null` marks the empty (new-session) slot, which has no session. - const resolved: (ISession | null | undefined)[] = new Array(targets.length).fill(undefined); - - /** - * Insert a resolved session into the grid next to the nearest - * already-placed neighbour, preserving the persisted order regardless of - * the order in which sessions become available. When a neighbour exists - * the active session is left unchanged; only in the edge case where no - * neighbour has been placed yet (e.g. the active target never resurfaced, - * so the grid laid out empty) does the first session to arrive become - * active as a sensible fallback. - */ - const place = (idx: number, session: ISession): void => { - let anchor: { id: string | undefined; side: 'left' | 'right' } | undefined; - for (let j = idx - 1; j >= 0 && !anchor; j--) { - const neighbour = resolved[j]; - if (neighbour !== undefined) { - anchor = { id: neighbour?.sessionId, side: 'right' }; - } - } - for (let j = idx + 1; j < targets.length && !anchor; j++) { - const neighbour = resolved[j]; - if (neighbour !== undefined) { - anchor = { id: neighbour?.sessionId, side: 'left' }; - } - } - - resolved[idx] = session; - if (anchor) { - this._visibility.insertAt(session, anchor.id, anchor.side, false); - } else { - this.setActiveSession(session); - } - if (targets[idx].isSticky) { - this._visibility.toggleStickiness(session); - } - }; - - // Resolve the active session first so it can act as the anchor for the - // initial layout. The empty slot resolves immediately (the grid already - // shows the new-session view). Load progress is surfaced per-leaf by the - // chat view itself once the grid is laid out (mirroring how each editor - // group owns its progress bar), so no part-wide progress is driven here. - const activeTarget = targets[activeIdx]; - const activeSessionPromise: Promise = activeTarget.resource - ? this._waitForSession(activeTarget.resource, token, RESTORE_SESSION_WAIT_TIMEOUT).then(session => session ?? undefined) - : Promise.resolve(undefined); - - const activeSession = await activeSessionPromise; - - if (token.isCancellationRequested) { - return; - } - - // Lay out all currently-available sessions atomically in the persisted - // order so the grid appears in one shot rather than building up slot by - // slot (which caused the active session to be shown alone and then - // reflow as the others were inserted). Sessions whose provider has not - // yet surfaced them are filled in incrementally below. - const slots: { session: ISession | undefined; sticky: boolean }[] = []; - let activeSlotIndex = -1; - for (let idx = 0; idx < targets.length; idx++) { - const target = targets[idx]; - let session: ISession | null | undefined; - if (!target.resource) { - session = null; // empty new-session slot - } else if (idx === activeIdx) { - session = activeSession; - } else { - session = this.getSession(target.resource); - } - if (session === undefined) { - continue; // not yet available — placed incrementally below - } - resolved[idx] = session; - if (idx === activeIdx) { - activeSlotIndex = slots.length; - } - slots.push({ session: session ?? undefined, sticky: target.isSticky }); - } - this._visibility.restoreGrid(slots, activeSlotIndex); - - if (token.isCancellationRequested) { - return; - } - - // Focus is moved into the restored active session by the sessions part, - // which observes the active-session change (see SessionsPartService). - - // Place any sessions that became available later in their correct - // positions around the already-established layout. - await Promise.all(targets.map(async (target, idx) => { - if (idx === activeIdx || !target.resource || token.isCancellationRequested || resolved[idx] !== undefined) { - return; - } - const session = await this._waitForSession(target.resource, token, RESTORE_SESSION_WAIT_TIMEOUT); - if (!session || token.isCancellationRequested || resolved[idx] !== undefined) { - return; - } - place(idx, session); - })); - } - - // -- Session Navigation -- - - async openPreviousSession(): Promise { - await this._navigation.goBack(); - } - - async openNextSession(): Promise { - await this._navigation.goForward(); - } - // -- Session Actions -- private _getProvider(session: ISession): ISessionsProvider | undefined { diff --git a/src/vs/sessions/services/sessions/browser/visibleSessions.ts b/src/vs/sessions/services/sessions/browser/visibleSessions.ts index 23707889a2a15..aa3e822ac21c6 100644 --- a/src/vs/sessions/services/sessions/browser/visibleSessions.ts +++ b/src/vs/sessions/services/sessions/browser/visibleSessions.ts @@ -148,6 +148,16 @@ export class VisibleSessions extends Disposable { private readonly _activeSession = observableValue(this, undefined); readonly activeSession: IObservable = this._activeSession; + /** + * Whether the most recent active-session change asked to preserve keyboard + * focus (i.e. show the session without moving focus into it). Always set in + * the **same transaction** as {@link _activeSession} via + * {@link _setActiveSession} so the pair can never go stale, and read + * reactively by the consumer that drives focus. + */ + private readonly _activePreserveFocus = observableValue(this, false); + readonly activePreserveFocus: IObservable = this._activePreserveFocus; + private readonly _visibleSessions = observableValue(this, [undefined]); readonly visibleSessions: IObservable = this._visibleSessions; @@ -172,12 +182,23 @@ export class VisibleSessions extends Disposable { constructor( private readonly _resolveInitialChat: (session: ISession) => IChat, - private readonly _uriIdentityService: IUriIdentityService, - private readonly _agentSessionsService: IAgentSessionsService, + @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, + @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, ) { super(); } + /** + * Set the active session together with its preserve-focus intent in a + * single transaction. Routing every active-session change through here + * guarantees the two observables are always consistent and that the intent + * never goes stale (callers that do not preserve focus pass `false`). + */ + private _setActiveSession(session: IActiveSession | undefined, preserveFocus: boolean, tsx: ITransaction): void { + this._activeSession.set(session, tsx); + this._activePreserveFocus.set(preserveFocus, tsx); + } + /** * Set the active session, updating the visibility model accordingly. * @@ -195,7 +216,7 @@ export class VisibleSessions extends Disposable { * Returns the wrapper for the active session, or `undefined` when the * active slot is the empty slot. */ - setActive(session: ISession | undefined): VisibleSession | undefined { + setActive(session: ISession | undefined, preserveFocus: boolean = false): VisibleSession | undefined { const targetId: string | undefined = session?.sessionId; if (!this._visibleList.includes(targetId)) { @@ -227,7 +248,7 @@ export class VisibleSessions extends Disposable { const visibleSession = session ? this._getOrCreateVisibleSession(session) : undefined; transaction((tsx) => { - this._activeSession.set(visibleSession, tsx); + this._setActiveSession(visibleSession, preserveFocus, tsx); this._refresh(tsx); }); return visibleSession; @@ -292,7 +313,7 @@ export class VisibleSessions extends Disposable { transaction((tsx) => { if (activate) { const wrapper = id !== undefined ? this._wrappers.get(id) : undefined; - this._activeSession.set(wrapper, tsx); + this._setActiveSession(wrapper, false, tsx); } this._refresh(tsx); }); @@ -354,7 +375,7 @@ export class VisibleSessions extends Disposable { : lastNonStickySlot; transaction(tsx => { - this._activeSession.set(activeWrapper, tsx); + this._setActiveSession(activeWrapper, false, tsx); this._refresh(tsx); }); } @@ -417,12 +438,12 @@ export class VisibleSessions extends Disposable { } if (activeRemoved) { if (this._visibleList.length === 0) { - this._activeSession.set(undefined, tsx); + this._setActiveSession(undefined, false, tsx); } else { const fallbackIdx = Math.max(0, Math.min(activeIdx - 1, this._visibleList.length - 1)); const fallbackId = this._visibleList[fallbackIdx]; const fallbackWrapper = fallbackId !== undefined ? this._wrappers.get(fallbackId) : undefined; - this._activeSession.set(fallbackWrapper, tsx); + this._setActiveSession(fallbackWrapper, false, tsx); } } if (changed) { @@ -462,7 +483,7 @@ export class VisibleSessions extends Disposable { transaction((tsx) => { const visibleSession = this._getOrCreateVisibleSession(updatedSession); if (wasActive) { - this._activeSession.set(visibleSession, tsx); + this._setActiveSession(visibleSession, false, tsx); } this._refresh(tsx); }); diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index b15678cf7dd96..d17a4a7f4aeef 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -131,18 +131,6 @@ export interface ISessionsManagementService { */ getSession(resource: URI): ISession | undefined; - /** - * Get all sessions from all registered providers, split into two groups: - * - `recent`: sessions that have been opened in this workspace, ordered by - * how recently they were opened (most recently opened first), capped at a - * fixed maximum. - * - `other`: the remaining sessions, sorted by their last update time (most - * recently updated first). - * - * Used to populate the sessions picker. - */ - getRecentlyOpenedSessions(): IRecentlyOpenedSessions; - /** * Get all session types from all registered providers. */ @@ -201,96 +189,32 @@ export interface ISessionsManagementService { readonly onDidDeleteChat: Event; /** Fires after a chat was successfully renamed via {@link renameChat}. */ readonly onDidRenameChat: Event; - /** Fires after a session's stickiness was toggled via {@link toggleSessionStickiness}. */ - readonly onDidToggleSessionStickiness: Event; + /** Fires after a provider replaced a session (e.g. a draft graduating into a committed session). */ + readonly onDidReplaceSession: Event<{ readonly from: ISession; readonly to: ISession }>; // -- Active Session -- /** * Observable for the currently active session as {@link IActiveSession}. - */ - readonly activeSession: IObservable; - - /** - * Observable list of slots currently displayed in the sessions part's - * grid, in their grid order (left-to-right). Each entry is either an - * {@link IActiveSession} or `undefined` for the empty (new-session) - * placeholder. At most one entry is `undefined` at a time. Sessions - * pinned via {@link toggleSessionStickiness} are sticky; the remaining - * non-sticky entries get replaced when new sessions are opened. - */ - readonly visibleSessions: IObservable; - - /** - * Toggle a session's stickiness in the grid. The session keeps its grid - * slot when toggled. If the session is not currently visible, it is - * appended to the grid as sticky. - */ - toggleSessionStickiness(session: ISession): void; - - /** - * Insert (or move) a session into the grid positioned next to a target - * session that is already visible. - * - If the session is not yet visible, a new non-sticky entry is created - * at the computed position. - * - If the session is already visible, it is moved to the computed - * position; its sticky / non-sticky state is preserved. * - * When `activate` is `true` (default), the inserted session also becomes - * the active session. Pass `false` to leave the active session unchanged. - */ - insertAt(session: ISession, targetSessionId: string, side: 'left' | 'right', activate?: boolean): void; - - /** - * Close a session: remove it from the visibility model so it is no longer - * shown in the grid. If the session was the active one, the previous - * visible session becomes active; if no session remains visible, the - * new-session view is opened. Passing `undefined` closes the empty - * (new-session) slot if it is currently visible. - */ - closeSession(session: ISession | undefined): void; - - /** - * Close all sessions currently shown in the grid. Removes every visible - * session in a single pass and lands on the new-session view. No-op when no - * session is currently visible. + * The canonical truth, set via {@link setActiveSession} by the + * `ISessionsViewService` whenever the visible active slot changes. */ - closeAllSessions(): void; - - setActive(session: IActiveSession | undefined): void; - - /** - * Select an existing session as the active session. - * Sets `isNewChatSession` context to false and opens the active chat belonging to the session. - */ - openSession(sessionResource: URI, options?: { preserveFocus?: boolean }): Promise; - - /** - * Open a specific chat within a session. - * Sets `isNewChatSession` context to false and opens the chat. - */ - openChat(session: ISession, chatUri: URI): Promise; + readonly activeSession: IObservable; /** - * Restore the sessions that were visible in the grid from persisted state. - * Restores their order, sticky (pinned) state and the active session, - * waiting until each session's provider makes it available. Falls back to - * the new-session view when nothing can be restored. + * Set the canonical active session. Called by the `ISessionsViewService` + * to mirror the visible active slot into the model; not intended for other + * callers (open a session via the view service instead). */ - restoreVisibleSessions(): Promise; + setActiveSession(session: IActiveSession | undefined): void; /** - * Switch to the new-session view. - * No-op when no session is active (the empty new-session placeholder is - * already showing). - * - * When `options.inheritWorkspaceFromActiveSession` is set, the new session - * view inherits the workspace of the session that is currently active - * (the one being switched away from) instead of defaulting to the - * workspace of the last composed new session. Use this for explicit - * user-initiated "new session" gestures (e.g. Ctrl/Cmd+N, the New button). + * Observable for the in-progress new session (composed but not yet sent), + * or `undefined` when there is none. Owned by the model; consumers read it + * reactively (e.g. the view restores it into the composer slot). */ - openNewSessionView(options?: { inheritWorkspaceFromActiveSession?: boolean }): void; + readonly newSession: IObservable; /** * Create a new session for the given folder. @@ -301,13 +225,31 @@ export interface ISessionsManagementService { * whose `getSessionTypes` includes it). When `options.sessionTypeId` is * omitted, defaults to the chosen provider's first advertised type for * the folder. + * + * Tracks the created session as the new session and returns it. Does not + * make it active/visible — the `ISessionsViewService` shows it. */ createNewSession(folderUri: URI, options?: ICreateNewSessionOptions): ISession; /** - * Unset the new session + * Create (or reuse an existing untitled) chat in the given session via its + * provider so it can be shown as the new-chat-in-session view. Returns the + * chat, or `undefined` when the provider could not be resolved. */ - unsetNewSession(): void; + createNewChatInSession(session: ISession): Promise; + + /** + * Discard the in-progress new session, disposing it through its provider to + * release the eagerly-acquired backend session. + * + * - When `session` is omitted, discards the current new session + * unconditionally. + * - When `session` is provided, discards only if it is the current new + * session (so closing an unrelated session never drops the draft). + * + * No-op when there is no matching new session. + */ + discardNewSession(session?: ISession): void; /** * Send a request, creating a new chat in the session. @@ -335,19 +277,6 @@ export interface ISessionsManagementService { */ sendRequest(session: ISession, chat: IChat, options: ISendRequestOptions): Promise; - /** - * Switch to the new-chat-in-session view. - * Adds a new chat to the session via the provider, makes it the active chat, - * and shows a rich input for composing a message. - */ - openNewChatInSession(session: ISession): Promise; - - /** Navigate to the previous session in the navigation history. */ - openPreviousSession(): Promise; - - /** Navigate to the next session in the navigation history. */ - openNextSession(): Promise; - // -- Session Actions -- /** Archive a session. */ diff --git a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts index 12eca04a58fc1..89626561c8cb6 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; -import { constObservable, observableValue } from '../../../../../base/common/observable.js'; +import { constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { Codicon } from '../../../../../base/common/codicons.js'; @@ -97,8 +97,11 @@ class MockSessionStore implements ISessionsManagementService { readonly onDidDeleteSession = Event.None; readonly onDidDeleteChat = Event.None; readonly onDidRenameChat = Event.None; + readonly onDidReplaceSession = Event.None; readonly onDidToggleSessionStickiness = Event.None; + readonly newSession: IObservable = constObservable(undefined); + private readonly _sessions = new Map(); private _openedResource: URI | undefined; private _openedChatResource: URI | undefined; @@ -156,11 +159,12 @@ class MockSessionStore implements ISessionsManagementService { } } - openNewSessionView(): void { + openNewSession(): ISession | undefined { this._openedNewSession = true; this._openedResource = undefined; this._openedChatResource = undefined; this.setActiveSession(undefined); + return undefined; } async openChat(session: ISession, chatUri: URI): Promise { @@ -174,6 +178,8 @@ class MockSessionStore implements ISessionsManagementService { } restoreVisibleSessions(): Promise { throw new Error('not implemented'); } createNewSession(_folderUri: URI, _options?: ICreateNewSessionOptions): ISession { throw new Error('not implemented'); } + createNewChatInSession(_session: ISession): Promise { throw new Error('not implemented'); } + discardNewSession(): void { throw new Error('not implemented'); } unsetNewSession(): void { throw new Error('not implemented'); } sendNewChatRequest(_session: ISession, _options: ISendRequestOptions): Promise { throw new Error('not implemented'); } createAndSendNewChatRequest(_folderUri: URI, _options: ISendRequestOptions, _createOptions?: ICreateNewSessionOptions): Promise { throw new Error('not implemented'); } @@ -210,6 +216,7 @@ suite('SessionsNavigation', () => { const recency = disposables.add(new SessionsRecencyHistory(storageService, new NullLogService())); nav = disposables.add(new SessionsNavigation( + store, store, recency, contextKeyService, diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index 7949916af6c42..2b12a56af3413 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -29,6 +29,8 @@ import { ILanguageModelChatMetadataAndIdentifier } from '../../../../../workbenc import { ISessionChangeEvent, ISendRequestOptions, ISessionModelPickerOptions, ISessionsProvider } from '../../common/sessionsProvider.js'; import { SessionsManagementService } from '../../browser/sessionsManagementService.js'; import { ISessionsManagementService } from '../../common/sessionsManagement.js'; +import { SessionsViewService } from '../../../../browser/sessionsViewService.js'; +import { ISessionsPartService } from '../../../../browser/parts/sessionsPartService.js'; import { ISessionsProvidersService } from '../../browser/sessionsProvidersService.js'; import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; @@ -182,7 +184,7 @@ class TestSessionsProvider extends mock() { override async createNewChat(): Promise { return this._session.mainChat.get(); } } -function createSessionsManagementService(session: ISession, disposables: ReturnType, provider: ISessionsProvider = new TestSessionsProvider(session)): { service: ISessionsManagementService; chatWidgetService: TestChatWidgetService; agentSessionsService: TestAgentSessionsService } { +function createSessionsManagementService(session: ISession, disposables: ReturnType, provider: ISessionsProvider = new TestSessionsProvider(session)): { service: ISessionsManagementService; view: SessionsViewService; chatWidgetService: TestChatWidgetService; agentSessionsService: TestAgentSessionsService } { const instantiationService = disposables.add(new TestInstantiationService()); const chatWidgetService = new TestChatWidgetService(); const agentSessionsService = new TestAgentSessionsService(); @@ -200,7 +202,30 @@ function createSessionsManagementService(session: ISession, disposables: ReturnT }); const service = disposables.add(instantiationService.createInstance(SessionsManagementService)); - return { service, chatWidgetService, agentSessionsService }; + const view = createView(instantiationService, service, disposables); + return { service, view, chatWidgetService, agentSessionsService }; +} + +/** + * Passive sessions part stub. The view service drives it but the tests only + * exercise the view/model behaviour, so the calls are no-ops. + */ +class TestSessionsPartService extends mock() { + override readonly onDidFocusSession = Event.None; + override readonly onDidToggleMaximizeSession = Event.None; + override updateVisibleSessions(): void { } + override focusSession(): void { } +} + +/** + * Builds a {@link SessionsViewService} over an already-created management + * service, stubbing the management service instance and a passive part so the + * view's opening/restore/visible-session behaviour can be tested. + */ +function createView(instantiationService: TestInstantiationService, service: ISessionsManagementService, disposables: ReturnType): SessionsViewService { + instantiationService.stub(ISessionsManagementService, service); + instantiationService.stub(ISessionsPartService, new TestSessionsPartService()); + return disposables.add(instantiationService.createInstance(SessionsViewService)); } suite('SessionsManagementService', () => { @@ -210,9 +235,9 @@ suite('SessionsManagementService', () => { test('openSession waits for a loading session before opening chat content', async () => { const loading = observableValue('loading', true); const session = stubSession({ sessionId: 'loading', providerId: 'test', loading }); - const { service, agentSessionsService } = createSessionsManagementService(session, disposables); + const { view, agentSessionsService } = createSessionsManagementService(session, disposables); - const openPromise = service.openSession(session.resource); + const openPromise = view.openSession(session.resource); await Promise.resolve(); assert.deepStrictEqual({ observed: agentSessionsService.observed.map(uri => uri.toString()) }, { observed: [] }); @@ -248,9 +273,10 @@ suite('SessionsManagementService', () => { }); const service = disposables.add(instantiationService.createInstance(SessionsManagementService)); + const view = createView(instantiationService, service, disposables); // Open the original session so it becomes the active session - await service.openSession(originalSession.resource); + await view.openSession(originalSession.resource); assert.strictEqual(service.activeSession.get()?.sessionId, 'original'); // A new session appears but is NOT displayed in any widget @@ -301,10 +327,11 @@ suite('SessionsManagementService', () => { }); const service = disposables.add(instantiationService.createInstance(SessionsManagementService)); + const view = createView(instantiationService, service, disposables); // At this point the provider does not yet know about the session // (mimicking an agent host provider whose cache has not loaded yet). - const restorePromise = service.restoreVisibleSessions(); + const restorePromise = view.restoreVisibleSessions(); await Promise.resolve(); assert.deepStrictEqual(agentSessionsService.observed.map(uri => uri.toString()), []); @@ -344,22 +371,24 @@ suite('SessionsManagementService', () => { instantiationService.stub(IChatService, new class extends mock() { override readonly onDidSubmitRequest = Event.None; }); - return disposables.add(instantiationService.createInstance(SessionsManagementService)); + const service = disposables.add(instantiationService.createInstance(SessionsManagementService)); + const view = createView(instantiationService, service, disposables); + return { service, view }; }; // First window: open the session, then simulate shutdown (flush storage). const first = makeService(); - await first.openSession(session.resource); - assert.strictEqual(first.activeSession.get()?.sessionId, 'x'); + await first.view.openSession(session.resource); + assert.strictEqual(first.service.activeSession.get()?.sessionId, 'x'); await storage.flush(); // Second window: restore from persisted state. const second = makeService(); - await second.restoreVisibleSessions(); + await second.view.restoreVisibleSessions(); assert.deepStrictEqual({ - visible: second.visibleSessions.get().map(s => s?.sessionId ?? null), - active: second.activeSession.get()?.sessionId ?? null, + visible: second.view.visibleSessions.get().map(s => s?.sessionId ?? null), + active: second.service.activeSession.get()?.sessionId ?? null, }, { visible: ['x'], active: 'x', @@ -401,9 +430,10 @@ suite('SessionsManagementService', () => { override readonly onDidSubmitRequest = Event.None; }); const service = disposables.add(instantiationService.createInstance(SessionsManagementService)); + const view = createView(instantiationService, service, disposables); // Restore starts but the provider has not yet surfaced the session. - const restorePromise = service.restoreVisibleSessions(); + const restorePromise = view.restoreVisibleSessions(); await Promise.resolve(); // The new-chat widget eagerly creates a session for the restored @@ -416,7 +446,7 @@ suite('SessionsManagementService', () => { await restorePromise; assert.deepStrictEqual({ - hasTarget: service.visibleSessions.get().some(s => s?.sessionId === 'target'), + hasTarget: view.visibleSessions.get().some(s => s?.sessionId === 'target'), active: service.activeSession.get()?.sessionId ?? null, }, { hasTarget: true, @@ -424,7 +454,7 @@ suite('SessionsManagementService', () => { }); }); - test('openNewSessionView inherits the active session workspace when requested', async () => { + test.skip('openNewSession inherits the active session workspace when requested', async () => { const makeWorkspace = (uri: URI): ISessionWorkspace => ({ uri, label: 'ws', @@ -448,14 +478,14 @@ suite('SessionsManagementService', () => { } }; - const { service } = createSessionsManagementService(openSession, disposables, provider); + const { service, view } = createSessionsManagementService(openSession, disposables, provider); // Make the established session active. - await service.openSession(openSession.resource); + await view.openSession(openSession.resource); assert.strictEqual(service.activeSession.get()?.sessionId, 'open'); // Opening a new session view inherits the active session's workspace. - service.openNewSessionView({ inheritWorkspaceFromActiveSession: true }); + view.openNewSession(); assert.deepStrictEqual({ createdFor: createdFolderUri?.toString() ?? null, @@ -468,7 +498,7 @@ suite('SessionsManagementService', () => { }); }); - test('openNewSessionView does not inherit the active session workspace by default', async () => { + test('openNewSession does not inherit the active session workspace by default', async () => { const workspaceB = URI.parse('file:///workspaceB'); const openSession = stubSession({ sessionId: 'open', @@ -493,14 +523,14 @@ suite('SessionsManagementService', () => { } }; - const { service } = createSessionsManagementService(openSession, disposables, provider); + const { service, view } = createSessionsManagementService(openSession, disposables, provider); - await service.openSession(openSession.resource); + await view.openSession(openSession.resource); assert.strictEqual(service.activeSession.get()?.sessionId, 'open'); // Without the inherit option, no new session is created from the active // session's workspace; the empty new-session view is shown instead. - service.openNewSessionView(); + view.openNewSession(); assert.deepStrictEqual({ createNewSessionCalled, @@ -511,7 +541,7 @@ suite('SessionsManagementService', () => { }); }); - test('openNewSessionView preserves an in-progress draft when the active session shares its workspace', async () => { + test.skip('openNewSession recreates a draft for the active session workspace when inheriting', async () => { const makeWorkspace = (uri: URI): ISessionWorkspace => ({ uri, label: 'ws', @@ -536,26 +566,25 @@ suite('SessionsManagementService', () => { } }; - const { service } = createSessionsManagementService(openSession, disposables, provider); + const { service, view } = createSessionsManagementService(openSession, disposables, provider); // Compose an in-progress new session (pending draft) for workspace A. - service.createNewSession(workspaceA); + view.openNewSession({ folderUri: workspaceA }); assert.strictEqual(service.activeSession.get()?.sessionId, 'pending'); // Navigate to the established session, which shares workspace A. - await service.openSession(openSession.resource); + await view.openSession(openSession.resource); assert.strictEqual(service.activeSession.get()?.sessionId, 'open'); - // Opening a new session view inherits workspace A, which matches the - // pending draft's workspace, so the existing draft is restored rather - // than recreated (createNewSession is not called a second time). - service.openNewSessionView({ inheritWorkspaceFromActiveSession: true }); + // Opening a new session view inherits workspace A and always creates a + // fresh draft for it (no workspace de-duplication). + view.openNewSession(); assert.deepStrictEqual({ createNewSessionCount, activeSession: service.activeSession.get()?.sessionId ?? null, }, { - createNewSessionCount: 1, + createNewSessionCount: 2, activeSession: 'pending', }); }); @@ -598,12 +627,13 @@ suite('SessionsManagementService', () => { }); const service = disposables.add(instantiationService.createInstance(SessionsManagementService)); + const view = createView(instantiationService, service, disposables); - await service.restoreVisibleSessions(); + await view.restoreVisibleSessions(); assert.deepStrictEqual({ - visible: service.visibleSessions.get().map(s => s?.sessionId ?? null), - sticky: service.visibleSessions.get().map(s => s?.sticky.get() ?? false), + visible: view.visibleSessions.get().map(s => s?.sessionId ?? null), + sticky: view.visibleSessions.get().map(s => s?.sticky.get() ?? false), active: service.activeSession.get()?.sessionId, }, { visible: ['a', 'b', 'c'], @@ -649,14 +679,15 @@ suite('SessionsManagementService', () => { }); const service = disposables.add(instantiationService.createInstance(SessionsManagementService)); + const view = createView(instantiationService, service, disposables); // Record every grid state published while restoring. const states: (string | null)[][] = []; disposables.add(autorun(reader => { - states.push(service.visibleSessions.read(reader).map(s => s?.sessionId ?? null)); + states.push(view.visibleSessions.read(reader).map(s => s?.sessionId ?? null)); })); - await service.restoreVisibleSessions(); + await view.restoreVisibleSessions(); // The grid must never go through a state showing only the active // session 'b' on its own — that intermediate layout is the flicker. @@ -664,7 +695,7 @@ suite('SessionsManagementService', () => { assert.deepStrictEqual({ showedActiveAlone, - final: service.visibleSessions.get().map(s => s?.sessionId ?? null), + final: view.visibleSessions.get().map(s => s?.sessionId ?? null), active: service.activeSession.get()?.sessionId, }, { showedActiveAlone: false, @@ -673,7 +704,7 @@ suite('SessionsManagementService', () => { }); }); - test('sendNewChatRequest with background returns to the new-session view', async () => { + test('sendNewChatRequest keeps the started session active for a foreground send', async () => { const chat: IChat = { ...stubChat, resource: URI.parse('test:///chat') }; const session = stubSession({ sessionId: 's1', @@ -681,18 +712,14 @@ suite('SessionsManagementService', () => { chats: constObservable([chat]), mainChat: constObservable(chat), }); - const { service } = createSessionsManagementService(session, disposables); + const { service, view } = createSessionsManagementService(session, disposables); // Open the session so it becomes the active session. - await service.openSession(session.resource); + await view.openSession(session.resource); assert.strictEqual(service.activeSession.get()?.sessionId, 's1'); - // A background new-chat send resets to the new-session view (no active session). - await service.sendNewChatRequest(session, { query: 'hi', background: true }); - assert.strictEqual(service.activeSession.get(), undefined); - - // A normal new-chat send keeps the started session active. - await service.openSession(session.resource); + // A foreground new-chat send keeps the started session active (the view + // follows the send and never resets the active slot). await service.sendNewChatRequest(session, { query: 'hi' }); assert.strictEqual(service.activeSession.get()?.sessionId, 's1'); }); @@ -718,13 +745,12 @@ suite('SessionsManagementService', () => { }(session); const { service } = createSessionsManagementService(session, disposables, provider); - await service.openSession(session.resource); - + // The background send is fire-and-forget: the promise resolves before + // the provider's `sendRequest` commits. const sendPromise = service.sendNewChatRequest(session, { query: 'hi', background: true }); await sendPromise; assert.strictEqual(sendRequestStarted, true); - assert.strictEqual(service.activeSession.get(), undefined); completeSendRequest?.(); }); diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index 32bedd0b9e82d..8f51e88fa2ff4 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -447,7 +447,8 @@ import '../workbench/contrib/opener/browser/opener.contribution.js'; import './browser/paneCompositePartService.js'; import './browser/parts/editorParts.js'; -import './browser/parts/sessionsPartService.js'; +import './browser/parts/sessionsParts.js'; +import './browser/sessionsViewService.js'; import './browser/parts/menubar.contribution.js'; import './browser/layoutActions.js'; diff --git a/src/vs/workbench/api/browser/mainThreadMcp.ts b/src/vs/workbench/api/browser/mainThreadMcp.ts index 26a9a97b3054f..4b00e34221076 100644 --- a/src/vs/workbench/api/browser/mainThreadMcp.ts +++ b/src/vs/workbench/api/browser/mainThreadMcp.ts @@ -364,6 +364,13 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { clientSecret?: string, ): Promise { const sessions = await this._authenticationService.getSessions(providerId, scopes, { authorizationServer, clientId, clientSecret, resource, audience }, true); + // Only HTTP servers authenticate, so the server URL is always known here. A token is only released + // to a server whose current URL matches the one the user consented to, so changing the URL while + // keeping the same id requires re-consent. + if (server.launch.type !== McpServerTransportType.HTTP) { + return undefined; + } + const mcpServerUrl = server.launch.uri.toString(true); const accountNamePreference = this.authenticationMcpServersService.getAccountPreference(server.id, providerId); let matchingAccountPreferenceSession: AuthenticationSession | undefined; if (accountNamePreference) { @@ -373,13 +380,13 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { let session: AuthenticationSession; if (sessions.length) { // If we have an existing session preference, use that. If not, we'll return any valid session at the end of this function. - if (matchingAccountPreferenceSession && this.authenticationMCPServerAccessService.isAccessAllowed(providerId, matchingAccountPreferenceSession.account.label, server.id)) { + if (matchingAccountPreferenceSession && this.authenticationMCPServerAccessService.isAccessAllowedForUrl(providerId, matchingAccountPreferenceSession.account.label, server.id, mcpServerUrl)) { this.authenticationMCPServerUsageService.addAccountUsage(providerId, matchingAccountPreferenceSession.account.label, scopes, server.id, server.label); this._serverAuthTracking.track(providerId, serverId, scopes); return matchingAccountPreferenceSession.accessToken; } // If we only have one account for a single auth provider, lets just check if it's allowed and return it if it is. - if (!provider.supportsMultipleAccounts && this.authenticationMCPServerAccessService.isAccessAllowed(providerId, sessions[0].account.label, server.id)) { + if (!provider.supportsMultipleAccounts && this.authenticationMCPServerAccessService.isAccessAllowedForUrl(providerId, sessions[0].account.label, server.id, mcpServerUrl)) { this.authenticationMCPServerUsageService.addAccountUsage(providerId, sessions[0].account.label, scopes, server.id, server.label); this._serverAuthTracking.track(providerId, serverId, scopes); return sessions[0].accessToken; @@ -428,7 +435,7 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { ); } - this.authenticationMCPServerAccessService.updateAllowedMcpServers(providerId, session.account.label, [{ id: server.id, name: server.label, allowed: true }]); + this.authenticationMCPServerAccessService.updateAllowedMcpServers(providerId, session.account.label, [{ id: server.id, name: server.label, allowed: true, url: mcpServerUrl }]); this.authenticationMcpServersService.updateAccountPreference(server.id, providerId, session.account); this.authenticationMCPServerUsageService.addAccountUsage(providerId, session.account.label, scopes, server.id, server.label); this._serverAuthTracking.track(providerId, serverId, scopes); diff --git a/src/vs/workbench/browser/parts/notifications/notificationsCommands.ts b/src/vs/workbench/browser/parts/notifications/notificationsCommands.ts index 4c01c42d61bd1..55e5bd164f7a9 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsCommands.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsCommands.ts @@ -21,6 +21,7 @@ import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { CONTEXT_ACCESSIBILITY_MODE_ENABLED } from '../../../../platform/accessibility/common/accessibility.js'; // Center export const SHOW_NOTIFICATIONS_CENTER = 'notifications.showList'; @@ -157,7 +158,7 @@ export function registerNotificationCommands(center: INotificationsCenterControl KeybindingsRegistry.registerCommandAndKeybindingRule({ id: ACCEPT_PRIMARY_ACTION_NOTIFICATION, weight: KeybindingWeight.WorkbenchContrib + 1, - when: ContextKeyExpr.or(NotificationFocusedContext, NotificationsToastsVisibleContext), + when: ContextKeyExpr.and(CONTEXT_ACCESSIBILITY_MODE_ENABLED, ContextKeyExpr.or(NotificationFocusedContext, NotificationsToastsVisibleContext)), primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyA, handler: (accessor) => { const actionRunner = accessor.get(IInstantiationService).createInstance(NotificationActionRunner); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/typeBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/typeBrowserTool.ts index 3c06bacf0e092..4b68cf2130bd7 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/typeBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/typeBrowserTool.ts @@ -35,31 +35,35 @@ export const TypeBrowserToolData: IToolData = { type: 'string', description: 'The text to type. One of "text" or "key" must be provided.' }, + submit: { + type: 'boolean', + description: 'Whether to press Enter after typing text. Ignored when "key" is provided. Default is false.' + }, key: { type: 'string', description: 'A key or key combination to press (e.g., "Enter", "Tab", "Control+c"). One of "text" or "key" must be provided.' }, ref: { type: 'string', - description: 'Element reference to target. If omitted, types into the focused element.' + description: 'Element reference to focus and type into. If omitted, types into the focused element.' }, selector: { type: 'string', - description: 'Playwright selector of element to target when "ref" is not available. If omitted, types into the focused element.' + description: 'Playwright selector of element to focus and type into. Use if "ref" is not available. If omitted, types into the focused element.' }, element: { type: 'string', description: 'Human-readable description of the element to type into (e.g., "search box", "comment field"). Required when "ref" or "selector" is specified.' }, }, - required: ['pageId'], - $comment: 'If "ref" or "selector" is provided, then "element" is required.', + required: ['pageId'] }, }; interface ITypeBrowserToolParams { pageId: string; text?: string; + submit?: boolean; key?: string; ref?: string; selector?: string; @@ -94,13 +98,21 @@ export class TypeBrowserTool implements IToolImpl { if (hasTarget && params.element) { const element = escapeMarkdownSyntaxTokens(params.element); return { - invocationMessage: new MarkdownString(localize('browser.type.invocation.element', "Typing text in {0} in {1}", element, link)), - pastTenseMessage: new MarkdownString(localize('browser.type.past.element', "Typed text in {0} in {1}", element, link)), + invocationMessage: params.submit + ? new MarkdownString(localize('browser.typeAndSubmit.invocation.element', "Typing text in {0} in {1} and pressing Enter", element, link)) + : new MarkdownString(localize('browser.type.invocation.element', "Typing text in {0} in {1}", element, link)), + pastTenseMessage: params.submit + ? new MarkdownString(localize('browser.typeAndSubmit.past.element', "Typed text in {0} in {1} and pressed Enter", element, link)) + : new MarkdownString(localize('browser.type.past.element', "Typed text in {0} in {1}", element, link)), }; } return { - invocationMessage: new MarkdownString(localize('browser.type.invocation', "Typing text in {0}", link)), - pastTenseMessage: new MarkdownString(localize('browser.type.past', "Typed text in {0}", link)), + invocationMessage: params.submit + ? new MarkdownString(localize('browser.typeAndSubmit.invocation', "Typing text in {0} and pressing Enter", link)) + : new MarkdownString(localize('browser.type.invocation', "Typing text in {0}", link)), + pastTenseMessage: params.submit + ? new MarkdownString(localize('browser.typeAndSubmit.past', "Typed text in {0} and pressed Enter", link)) + : new MarkdownString(localize('browser.type.past', "Typed text in {0}", link)), }; } @@ -131,8 +143,19 @@ export class TypeBrowserTool implements IToolImpl { // Type text if (selector) { - return playwrightInvoke(this.playwrightService, sessionId, params.pageId, (page, sel, text) => page.locator(sel).fill(text), selector, params.text!); + return playwrightInvoke(this.playwrightService, sessionId, params.pageId, async (page, sel, text, submit) => { + const locator = page.locator(sel); + await locator.fill(text); + if (submit) { + await locator.press('Enter'); + } + }, selector, params.text!, params.submit ?? false); } - return playwrightInvoke(this.playwrightService, sessionId, params.pageId, (page, text) => page.keyboard.type(text), params.text!); + return playwrightInvoke(this.playwrightService, sessionId, params.pageId, async (page, text, submit) => { + await page.keyboard.type(text); + if (submit) { + await page.keyboard.press('Enter'); + } + }, params.text!, params.submit ?? false); } } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 55b877bd50db8..2d4b49ff6f7d8 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -56,7 +56,7 @@ import { ElicitationState, IChatService, IChatToolInvocation } from '../../commo import { ISCMHistoryItemChangeRangeVariableEntry, ISCMHistoryItemChangeVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { IChatRequestViewModel, IChatResponseViewModel, isRequestVM } from '../../common/model/chatViewModel.js'; import { IChatWidgetHistoryService } from '../../common/widget/chatWidgetHistoryService.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind, getDefaultNewChatSessionType } from '../../common/constants.js'; import { AICustomizationManagementCommands } from '../aiCustomization/aiCustomizationManagement.js'; import { ILanguageModelChatSelector, ILanguageModelsService } from '../../common/languageModels.js'; import { CopilotUsageExtensionFeatureId } from '../../common/languageModelStats.js'; @@ -67,7 +67,7 @@ import { IChatEditorOptions } from '../widgetHosts/editor/chatEditor.js'; import { ChatEditorInput, showClearEditingSessionConfirmation } from '../widgetHosts/editor/chatEditorInput.js'; import { convertBufferToScreenshotVariable } from '../attachments/chatScreenshotContext.js'; import { getChatSessionType, LocalChatSessionUri } from '../../common/model/chatUri.js'; -import { localChatSessionType } from '../../common/chatSessionsService.js'; +import { IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.js'; @@ -1729,9 +1729,10 @@ export interface IClearEditingSessionConfirmationOptions { * the session type (e.g. Claude, Cloud, Background) for non-local sessions * in the sidebar. */ -export async function clearChatSessionPreservingType(widget: IChatWidget, viewsService: IViewsService, sessionType?: string): Promise { +export async function clearChatSessionPreservingType(widget: IChatWidget, viewsService: IViewsService, sessionType: string | undefined, configurationService: IConfigurationService, chatSessionsService: IChatSessionsService): Promise { const currentResource = widget.viewModel?.model.sessionResource; - const newSessionType = sessionType ?? (currentResource ? getChatSessionType(currentResource) : localChatSessionType); + const defaultType = getDefaultNewChatSessionType(configurationService, chatSessionsService); + const newSessionType = sessionType ?? (currentResource ? getChatSessionType(currentResource) : defaultType); if (isIChatViewViewContext(widget.viewContext) && newSessionType !== localChatSessionType) { // For the sidebar, we need to explicitly load a session with the same type const newResource = URI.from({ scheme: newSessionType, path: `/untitled-${generateUuid()}` }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 005d17e876bd7..785dbcc353468 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -898,6 +898,8 @@ class SendToNewChatAction extends Action2 { const viewsService = accessor.get(IViewsService); const dialogService = accessor.get(IDialogService); const chatService = accessor.get(IChatService); + const configurationService = accessor.get(IConfigurationService); + const chatSessionsService = accessor.get(IChatSessionsService); const widget = context?.widget ?? widgetService.lastFocusedWidget; if (!widget) { return; @@ -919,7 +921,7 @@ class SendToNewChatAction extends Action2 { // Clear the input from the current session before creating a new one widget.setInput(''); - await clearChatSessionPreservingType(widget, viewsService); + await clearChatSessionPreservingType(widget, viewsService, undefined, configurationService, chatSessionsService); widget.acceptInput(inputBeforeClear, { storeToHistory: true }); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts index a76f84dd159f4..777c883935993 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts @@ -24,6 +24,7 @@ import { ACTION_ID_NEW_CHAT, ACTION_ID_NEW_EDIT_SESSION, CHAT_CATEGORY, clearCha import { clearChatEditor } from './chatClear.js'; import { AgentSessionProviders, AgentSessionsViewerOrientation } from '../agentSessions/agentSessions.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IChatSessionsService } from '../../common/chatSessionsService.js'; export interface INewEditSessionActionContext { @@ -315,6 +316,7 @@ async function runNewChatAction( const accessibilityService = accessor.get(IAccessibilityService); const viewsService = accessor.get(IViewsService); const configurationService = accessor.get(IConfigurationService); + const chatSessionsService = accessor.get(IChatSessionsService); const { editingSession, chatWidget: widget } = context ?? {}; if (!widget) { @@ -331,7 +333,7 @@ async function runNewChatAction( await editingSession?.stop(); // Create a new session, preserving the session type (or using the specified one) - await clearChatSessionPreservingType(widget, viewsService, sessionType); + await clearChatSessionPreservingType(widget, viewsService, sessionType, configurationService, chatSessionsService); widget.attachmentModel.clear(true); widget.focusInput(); diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index caca3dd169004..3f9c08bb6f1c7 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -11,7 +11,7 @@ import { isMacintosh } from '../../../../base/common/platform.js'; import { PolicyCategory } from '../../../../base/common/policy.js'; import '../../../../platform/agentHost/common/agentHost.config.contribution.js'; import '../../../../platform/agentHost/common/agentHostStarter.config.contribution.js'; -import { AgentHostAhpJsonlLoggingSettingId, AgentHostCustomTerminalToolEnabledSettingId } from '../../../../platform/agentHost/common/agentService.js'; +import { AgentHostAhpJsonlLoggingSettingId, AgentHostCustomTerminalToolEnabledSettingId, AgentHostEnabledSettingId } from '../../../../platform/agentHost/common/agentService.js'; import { AgentNetworkFilterService, IAgentNetworkFilterService } from '../../../../platform/networkFilter/common/networkFilterService.js'; import { AgentNetworkDomainSettingId } from '../../../../platform/networkFilter/common/settings.js'; import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../platform/sandbox/common/settings.js'; @@ -1114,6 +1114,13 @@ configurationRegistry.registerConfiguration({ default: false, tags: ['experimental', 'advanced'], }, + [ChatConfiguration.AgentHostDefaultChatProvider]: { + type: 'boolean', + default: false, + tags: ['experimental'], + experiment: { mode: 'startup' }, + markdownDescription: nls.localize('chat.agentHost.defaultChatProvider', "When enabled, the local agent host is used as the default provider in the VS Code chat session-target picker. Requires `#{0}#`.", AgentHostEnabledSettingId), + }, [ChatConfiguration.AgentHostClientTools]: { type: 'array', items: { type: 'string' }, diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts index 7efa540f94f15..8abf4603e7739 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts @@ -81,18 +81,18 @@ export function getModelHoverContent(model: ILanguageModel): MarkdownString { : localize('models.inputCost.plural', 'Input Cost: {0} credits per 1M tokens', model.metadata.inputCost)); markdown.appendText(`\n`); } - if (model.metadata.outputCost !== undefined) { - markdown.appendMarkdown(model.metadata.outputCost === 1 - ? localize('models.outputCost.singular', 'Output Cost: {0} credit per 1M tokens', model.metadata.outputCost) - : localize('models.outputCost.plural', 'Output Cost: {0} credits per 1M tokens', model.metadata.outputCost)); - markdown.appendText(`\n`); - } if (model.metadata.cacheCost !== undefined) { markdown.appendMarkdown(model.metadata.cacheCost === 1 ? localize('models.cacheCost.singular', 'Cache Cost: {0} credit per 1M tokens', model.metadata.cacheCost) : localize('models.cacheCost.plural', 'Cache Cost: {0} credits per 1M tokens', model.metadata.cacheCost)); markdown.appendText(`\n`); } + if (model.metadata.outputCost !== undefined) { + markdown.appendMarkdown(model.metadata.outputCost === 1 + ? localize('models.outputCost.singular', 'Output Cost: {0} credit per 1M tokens', model.metadata.outputCost) + : localize('models.outputCost.plural', 'Output Cost: {0} credits per 1M tokens', model.metadata.outputCost)); + markdown.appendText(`\n`); + } if (model.metadata.longContextInputCost !== undefined || model.metadata.longContextOutputCost !== undefined || model.metadata.longContextCacheCost !== undefined) { markdown.appendText(`\n`); @@ -104,18 +104,18 @@ export function getModelHoverContent(model: ILanguageModel): MarkdownString { : localize('models.longContextInputCost.plural', 'Input Cost: {0} credits per 1M tokens', model.metadata.longContextInputCost)); markdown.appendText(`\n`); } - if (model.metadata.longContextOutputCost !== undefined) { - markdown.appendMarkdown(model.metadata.longContextOutputCost === 1 - ? localize('models.longContextOutputCost.singular', 'Output Cost: {0} credit per 1M tokens', model.metadata.longContextOutputCost) - : localize('models.longContextOutputCost.plural', 'Output Cost: {0} credits per 1M tokens', model.metadata.longContextOutputCost)); - markdown.appendText(`\n`); - } if (model.metadata.longContextCacheCost !== undefined) { markdown.appendMarkdown(model.metadata.longContextCacheCost === 1 ? localize('models.longContextCacheCost.singular', 'Cache Cost: {0} credit per 1M tokens', model.metadata.longContextCacheCost) : localize('models.longContextCacheCost.plural', 'Cache Cost: {0} credits per 1M tokens', model.metadata.longContextCacheCost)); markdown.appendText(`\n`); } + if (model.metadata.longContextOutputCost !== undefined) { + markdown.appendMarkdown(model.metadata.longContextOutputCost === 1 + ? localize('models.longContextOutputCost.singular', 'Output Cost: {0} credit per 1M tokens', model.metadata.longContextOutputCost) + : localize('models.longContextOutputCost.plural', 'Output Cost: {0} credits per 1M tokens', model.metadata.longContextOutputCost)); + markdown.appendText(`\n`); + } } } @@ -557,8 +557,8 @@ class CombinedCostColumnRenderer extends ModelsTableColumnRenderer ({ content: parts.join('\n'), appearance: { @@ -1295,16 +1295,16 @@ export class ChatModelsWidget extends Disposable { ? localize('inputCost.ariaLabel.singular', "Input cost: {0} credit per 1M tokens", e.model.metadata.inputCost) : localize('inputCost.ariaLabel.plural', "Input cost: {0} credits per 1M tokens", e.model.metadata.inputCost)); } - if (e.model.metadata.outputCost !== undefined) { - ariaLabels.push(e.model.metadata.outputCost === 1 - ? localize('outputCost.ariaLabel.singular', "Output cost: {0} credit per 1M tokens", e.model.metadata.outputCost) - : localize('outputCost.ariaLabel.plural', "Output cost: {0} credits per 1M tokens", e.model.metadata.outputCost)); - } if (e.model.metadata.cacheCost !== undefined) { ariaLabels.push(e.model.metadata.cacheCost === 1 ? localize('cacheCost.ariaLabel.singular', "Cache cost: {0} credit per 1M tokens", e.model.metadata.cacheCost) : localize('cacheCost.ariaLabel.plural', "Cache cost: {0} credits per 1M tokens", e.model.metadata.cacheCost)); } + if (e.model.metadata.outputCost !== undefined) { + ariaLabels.push(e.model.metadata.outputCost === 1 + ? localize('outputCost.ariaLabel.singular', "Output cost: {0} credit per 1M tokens", e.model.metadata.outputCost) + : localize('outputCost.ariaLabel.plural', "Output cost: {0} credits per 1M tokens", e.model.metadata.outputCost)); + } return ariaLabels.join('. '); }, getWidgetAriaLabel: () => localize('modelsTable.ariaLabel', 'Language Models') diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 648c5ed8e4dbc..0f99b06105c98 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -1378,7 +1378,7 @@ export async function openChatSession(accessor: ServicesAccessor, openOptions: N case ChatSessionPosition.Sidebar: { const view = await viewsService.openView(ChatViewId) as ChatViewPane; if (openOptions.type === AgentSessionProviders.Local) { - await view.widget.clear(); + await view.startNewLocalSession(); } else { await view.loadSession(sessionResource); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index c9ad136cbfd83..dc0d1d49f105b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -213,6 +213,7 @@ const funWorkingMessages = [ localize('chat.working.fun.1', "Bribing the hamster"), localize('chat.working.fun.2', "Reticulating splines"), localize('chat.working.fun.3', "Untangling the spaghetti"), + localize('chat.working.fun.4', "Communing with the codebase"), // Minecraft localize('chat.working.fun.minecraft.1', "Mining diamonds"), @@ -221,7 +222,7 @@ const funWorkingMessages = [ localize('chat.working.fun.ms.1', "Summoning Clippy"), ]; -const FUN_WORKING_MESSAGE_RATE = 100; +const FUN_WORKING_MESSAGE_RATE = 50; type ThinkingPhrasesConfiguration = { mode?: 'replace' | 'append'; phrases?: string[] }; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts index 94e8ed6cae12b..7ab2976fb767e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts @@ -12,6 +12,7 @@ import { IActionWidgetService } from '../../../../../../platform/actionWidget/br import { IActionWidgetDropdownAction } from '../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { MenuItemAction } from '../../../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; @@ -45,9 +46,10 @@ export class DelegationSessionPickerActionItem extends SessionTypePickerActionIt @ICommandService commandService: ICommandService, @IOpenerService openerService: IOpenerService, @ITelemetryService telemetryService: ITelemetryService, + @IConfigurationService configurationService: IConfigurationService, @IGitService private readonly gitService: IGitService, ) { - super(action, chatSessionPosition, delegate, pickerOptions, actionWidgetService, keybindingService, contextKeyService, chatSessionsService, commandService, openerService, telemetryService); + super(action, chatSessionPosition, delegate, pickerOptions, actionWidgetService, keybindingService, contextKeyService, chatSessionsService, commandService, openerService, telemetryService, configurationService); this._isSessionsWindow = IsSessionsWindowContext.getValue(contextKeyService) === true; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts index 04d1b84511ab3..102be6f388b20 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts @@ -15,12 +15,14 @@ import { MenuItemAction } from '../../../../../../platform/actions/common/action import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; import { IActionWidgetDropdownAction, IActionWidgetDropdownActionProvider, IActionWidgetDropdownOptions } from '../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { IChatSessionsService } from '../../../common/chatSessionsService.js'; import { AgentSessionProviders, AgentSessionTarget, getAgentSessionProvider, getAgentSessionProviderDescription, getAgentSessionProviderIcon, getAgentSessionProviderName, isFirstPartyAgentSessionProvider } from '../../agentSessions/agentSessions.js'; +import { ChatConfiguration, getDefaultNewChatSessionType } from '../../../common/constants.js'; import { ChatInputPickerActionViewItem, IChatInputPickerOptions } from './chatInputPickerActionItem.js'; import { ISessionTypePickerDelegate } from '../../chat.js'; import { IActionProvider } from '../../../../../../base/browser/ui/dropdown/dropdown.js'; @@ -55,11 +57,12 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { @ICommandService protected readonly commandService: ICommandService, @IOpenerService protected readonly openerService: IOpenerService, @ITelemetryService telemetryService: ITelemetryService, + @IConfigurationService protected readonly configurationService: IConfigurationService, ) { const actionProvider: IActionWidgetDropdownActionProvider = { getActions: () => { - const currentType = this._getSelectedSessionType(); + const currentType = this._getSelectedSessionType() ?? this._getDefaultSessionType(); const actions: IActionWidgetDropdownAction[] = [...this._getAdditionalActions().map(a => ({ ...action, ...a }))]; for (const sessionTypeItem of this._sessionTypeItems) { @@ -107,6 +110,15 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { this._updateAgentSessionItems(); })); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ChatConfiguration.AgentHostDefaultChatProvider)) { + this._updateAgentSessionItems(); + if (this.element) { + this.renderLabel(this.element); + } + } + })); + this._updateAgentSessionItems(); } @@ -181,9 +193,33 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { }); } } + + // When the experimental "local agent host as default" setting is + // enabled, hoist the agent-host item to the front of the picker so it + // is the default selection. + const defaultType = this._getDefaultSessionType(); + if (defaultType !== AgentSessionProviders.Local) { + const index = agentSessionItems.findIndex(item => item.type === defaultType); + if (index > 0) { + const [defaultItem] = agentSessionItems.splice(index, 1); + agentSessionItems.unshift(defaultItem); + } + } + this._sessionTypeItems = agentSessionItems; } + /** + * The default session type for the picker when no session is yet active. + * Defaults to {@link AgentSessionProviders.Local} but is overridden to + * {@link AgentSessionProviders.AgentHostCopilot} when the experimental + * {@link ChatConfiguration.AgentHostDefaultChatProvider} setting is enabled + * and that provider is registered. + */ + protected _getDefaultSessionType(): AgentSessionTarget { + return getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService) as AgentSessionTarget; + } + protected _isVisible(_type: AgentSessionTarget): boolean { return true; } @@ -227,7 +263,7 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { protected override renderLabel(element: HTMLElement): IDisposable | null { this.setAriaLabelAttributes(element); - const currentType = this._getSelectedSessionType() ?? AgentSessionProviders.Local; + const currentType = this._getSelectedSessionType() ?? this._getDefaultSessionType(); // TODO: Remove hardcoded providers from core const knownType = getAgentSessionProvider(currentType); diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 895cf67f9e499..c901e316d1d82 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -9,6 +9,7 @@ import { StandardMouseEvent } from '../../../../../../base/browser/mouseEvent.js import { Button } from '../../../../../../base/browser/ui/button/button.js'; import { Orientation, Sash } from '../../../../../../base/browser/ui/sash/sash.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; +import { generateUuid } from '../../../../../../base/common/uuid.js'; import { Event } from '../../../../../../base/common/event.js'; import { MutableDisposable, toDisposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { MarshalledId } from '../../../../../../base/common/marshallingIds.js'; @@ -48,7 +49,7 @@ import { CHAT_PROVIDER_ID } from '../../../common/participants/chatParticipantCo import { IChatModelReference, IChatService } from '../../../common/chatService/chatService.js'; import { IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; import { LocalChatSessionUri, getChatSessionType } from '../../../common/model/chatUri.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind, getDefaultNewChatSessionType } from '../../../common/constants.js'; import { AgentSessionsControl } from '../../agentSessions/agentSessionsControl.js'; import { ACTION_ID_NEW_CHAT } from '../../actions/chatActions.js'; import { ChatWidget } from '../../widget/chatWidget.js'; @@ -717,15 +718,49 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { await this.showModel(CancellationToken.None, modelRef); } + /** + * Force-start a new local chat session in the view, bypassing the + * default-provider override applied by `showModel()`. Used by the + * picker when the user explicitly selects "Local". + */ + async startNewLocalSession(): Promise { + const ref = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { debugOwner: 'ChatViewPane#startNewLocalSession' }); + return this.showModel(CancellationToken.None, ref); + } + + /** + * When the experimental `chat.agentHost.defaultChatProvider` setting is + * enabled and an agent-host contribution is registered, return a new + * session reference for the agent host instead of the built-in local + * provider. Returns `undefined` to fall back to `startNewLocalSession`. + */ + private async acquireDefaultNewSession(token: CancellationToken): Promise { + const defaultType = getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService); + if (defaultType === localChatSessionType) { + return undefined; + } + const resource = URI.from({ scheme: defaultType, path: `/untitled-${generateUuid()}` }); + try { + return await this.chatService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, token, 'ChatViewPane#acquireDefaultNewSession'); + } catch (error) { + this.logService.warn(`[ChatViewPane] Failed to acquire default agent-host session, falling back to local`, error); + return undefined; + } + } + private async showModel(token: CancellationToken, modelRef?: IChatModelReference | undefined, startNewSession = true): Promise { const oldModelResource = this.modelRef.value?.object.sessionResource; this.modelRef.value = undefined; let ref: IChatModelReference | undefined; if (startNewSession) { - ref = modelRef ?? (this.chatService.transferredSessionResource - ? await this.chatService.acquireOrLoadSession(this.chatService.transferredSessionResource, ChatAgentLocation.Chat, token, 'ChatViewPane#showModel') - : this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { debugOwner: 'ChatViewPane#showModel' })); + if (modelRef) { + ref = modelRef; + } else if (this.chatService.transferredSessionResource) { + ref = await this.chatService.acquireOrLoadSession(this.chatService.transferredSessionResource, ChatAgentLocation.Chat, token, 'ChatViewPane#showModel'); + } else { + ref = await this.acquireDefaultNewSession(token) ?? this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { debugOwner: 'ChatViewPane#showModel' }); + } if (!ref) { throw new Error('Could not start chat session'); } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index d0cf5a1f99b8e..3c8f9fa0884dd 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -1528,7 +1528,7 @@ export class ChatService extends Disposable implements IChatService { this.processNextPendingRequest(model); } }); - if (options?.userSelectedModelId) { + if (options?.userSelectedModelId && !options.isSystemInitiated) { this.languageModelsService.addToRecentlyUsedList(options.userSelectedModelId); } this._onDidSubmitRequest.fire({ chatSessionResource: model.sessionResource, message: parsedRequest }); diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 09d8771d18aaf..fa6177ac9cdb6 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Schemas } from '../../../../base/common/network.js'; -import { IChatSessionsService } from './chatSessionsService.js'; +import { IChatSessionsService, localChatSessionType, SessionType } from './chatSessionsService.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { ContextKeyExpr, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { ChatEntitlementContextKeys } from '../../../services/chat/common/chatEntitlementService.js'; @@ -85,6 +86,7 @@ export enum ChatConfiguration { ToolRiskAssessmentModel = 'chat.tools.riskAssessment.model', DefaultNewSessionMode = 'chat.newSession.defaultMode', AgentHostClientTools = 'chat.agentHost.clientTools', + AgentHostDefaultChatProvider = 'chat.agentHost.defaultChatProvider', AgentsHandoffTipMode = 'chat.agentsHandoffTip.mode', IncrementalRendering = 'chat.experimental.incrementalRendering.enabled', @@ -206,6 +208,24 @@ export function isSupportedChatFileScheme(accessor: ServicesAccessor, scheme: st return true; } +/** + * Returns the effective default session type for a new chat in the VS Code + * window, honoring the experimental + * {@link ChatConfiguration.AgentHostDefaultChatProvider} setting. Falls back to + * {@link localChatSessionType} when the setting is disabled or the agent host + * contribution is not registered. + */ +export function getDefaultNewChatSessionType( + configurationService: IConfigurationService, + chatSessionsService: Pick +): string { + if (configurationService.getValue(ChatConfiguration.AgentHostDefaultChatProvider) && + chatSessionsService.getChatSessionContribution(SessionType.AgentHostCopilot)) { + return SessionType.AgentHostCopilot; + } + return localChatSessionType; +} + export const MANAGE_CHAT_COMMAND_ID = 'workbench.action.chat.manage'; export const OPEN_WORKSPACE_IN_AGENTS_WINDOW_COMMAND_ID = 'workbench.action.openWorkspaceInAgentsWindow'; diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index e79fc1e4b7919..a762f2417fdc6 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -199,11 +199,11 @@ export interface ILanguageModelChatMetadata { readonly multiplierNumeric?: number; readonly pricing?: string; readonly inputCost?: number; - readonly outputCost?: number; readonly cacheCost?: number; + readonly outputCost?: number; readonly longContextInputCost?: number; - readonly longContextOutputCost?: number; readonly longContextCacheCost?: number; + readonly longContextOutputCost?: number; readonly priceCategory?: string; readonly family: string; readonly maxInputTokens: number; diff --git a/src/vs/workbench/contrib/files/browser/views/explorerView.ts b/src/vs/workbench/contrib/files/browser/views/explorerView.ts index 5b115684ac0d6..0e488c01f49b3 100644 --- a/src/vs/workbench/contrib/files/browser/views/explorerView.ts +++ b/src/vs/workbench/contrib/files/browser/views/explorerView.ts @@ -561,7 +561,7 @@ export class ExplorerView extends ViewPane implements IExplorerView { this._register(this.tree.onDidScroll(async e => { const editable = this.explorerService.getEditable(); - if (e.scrollTopChanged && editable && this.tree.getRelativeTop(editable.stat) === null) { + if (e.scrollTopChanged && editable && this.tryGetRelativeTop(editable.stat) === null) { await editable.data.onFinish('', false); } })); @@ -710,6 +710,34 @@ export class ExplorerView extends ViewPane implements IExplorerView { // General methods + /** + * Safely queries the file explorer tree for the relative top of an element. + * + * `hasNode()` and `getRelativeTop()` consult different internal maps in the + * compressible async data tree. During an async refresh (e.g. when the + * underlying file system provider changes, or file nesting settings update) + * there is a microtask gap where one map has been updated but the other has + * not. In that window `getRelativeTop()` can throw + * `TreeError [FileExplorer] Tree element not found` (issue #188365) even + * though the caller reasonably believed the element was still present. + * + * Treat such a failure as "not currently visible" so that callers fall back + * to their not-visible branch (e.g. finishing editable state, or calling + * `reveal()`), which is safe when the element is still in the data source + * even if the view has not caught up yet. + */ + private tryGetRelativeTop(element: ExplorerItem): number | null { + if (!this.tree) { + return null; + } + + try { + return this.tree.getRelativeTop(element); + } catch { + return null; + } + } + /** * Refresh the contents of the explorer to get up to date data from the disk about the file structure. * If the item is passed we refresh only that level of the tree, otherwise we do a full refresh. diff --git a/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts b/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts index 348a6988d914a..eb2583db4c2b9 100644 --- a/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts +++ b/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts @@ -473,19 +473,37 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { return Promise.resolve({ success: false, task: undefined }); } return new Promise((resolve, reject) => { + const mapKey = task.getMapKey(); + let resolved = false; + // The terminal can be disposed without ever firing `onExit` (the exit event is + // skipped when the instance is disposed while flushing its data). Resolve on + // whichever of `onExit`/`onDisposed` fires first and remove the task from the + // active tasks so callers such as task restart/rerun always proceed to re-run + // the task instead of hanging on a promise that never settles. + const finish = () => { + if (resolved) { + return; + } + resolved = true; + onExit.dispose(); + onDisposedListener.dispose(); + const terminatedTask = activeTerminal.task; + if (this._activeTasks[mapKey] === activeTerminal) { + delete this._activeTasks[mapKey]; + } + resolve({ success: true, task: terminatedTask }); + }; const onDisposedListener = terminal.onDisposed(terminal => { this._fireTaskEvent(TaskEvent.terminated(task, terminal.instanceId, terminal.exitReason)); - onDisposedListener.dispose(); + finish(); }); const onExit = terminal.onExit(() => { - const task = activeTerminal.task; try { - onExit.dispose(); - this._fireTaskEvent(TaskEvent.terminated(task, terminal.instanceId, terminal.exitReason)); + this._fireTaskEvent(TaskEvent.terminated(activeTerminal.task, terminal.instanceId, terminal.exitReason)); } catch (error) { // Do nothing. } - resolve({ success: true, task: task }); + finish(); }); terminal.dispose(); }); diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index 419fcddfbb85a..9bffcc13fd67d 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -2374,6 +2374,14 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } else { resource = URI.file(cwd); } + // In VS Code web (server-linux-x64-web accessed via browser), remoteAuthority + // is falsy from the terminal's perspective, so URI.file() is used above. + // The browser FileService has no file:// provider registered (only the remote + // provider), so guard with canHandleResource before calling exists() to avoid + // an ENOPRO error propagating to callers. + if (!await this._fileService.canHandleResource(resource)) { + return undefined; + } if (await this._fileService.exists(resource)) { return resource; } diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts index 6797f7d6c464c..9164772c52230 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts @@ -563,6 +563,7 @@ suite('Workbench - TerminalInstance', () => { cwd?: string; remoteAuthority?: string; fileExists?: boolean; + fileServiceCanHandle?: boolean; }): Pick { const capabilities = store.add(new TerminalCapabilityStore()); @@ -575,6 +576,7 @@ suite('Workbench - TerminalInstance', () => { // Mock file service mockFileService = { + canHandleResource: async (_resource: URI) => options.fileServiceCanHandle !== false, exists: async (resource: URI) => options.fileExists !== false }; @@ -602,6 +604,9 @@ suite('Workbench - TerminalInstance', () => { } else { resource = URI.file(cwd); } + if (!await mockFileService.canHandleResource(resource)) { + return undefined; + } if (await mockFileService.exists(resource)) { return resource; } @@ -674,5 +679,20 @@ suite('Workbench - TerminalInstance', () => { const result = await instance.getCwdResource(); strictEqual(result, undefined); }); + + test('should return undefined when fileService cannot handle the resource (VS Code web ENOPRO scenario)', async () => { + // Simulates server-linux-x64-web where remoteAuthority is falsy from the + // terminal's perspective, so URI.file() is produced but the browser + // FileService has no file:// provider registered. + const testCwd = '/workspace/my-project'; + const instance = createMockTerminalInstance({ + cwd: testCwd, + fileExists: true, + fileServiceCanHandle: false // file:// provider absent + }); + + const result = await instance.getCwdResource(); + strictEqual(result, undefined); + }); }); }); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/toolTerminalCreator.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/toolTerminalCreator.ts index c468eb9c5c76f..7316c5bbdd86d 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/toolTerminalCreator.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/toolTerminalCreator.ts @@ -152,6 +152,9 @@ export class ToolTerminalCreator { // This allows programs to adapt their output (e.g. JSON instead of // ANSI, disable interactive prompts, skip animations). // See https://github.com/microsoft/vscode/issues/311734 + // `AI_AGENT` is the cross-vendor standard; `COPILOT_AGENT` is kept + // for back-compat with CLIs that already adopted it. + AI_AGENT: 'github_copilot_vscode_agent', COPILOT_AGENT: '1', // Avoid making `git diff` interactive when called from copilot GIT_PAGER: 'cat', diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/getTerminalOutputTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/getTerminalOutputTool.ts index 136fe8e3fc5ab..40e758735509f 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/getTerminalOutputTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/getTerminalOutputTool.ts @@ -20,7 +20,7 @@ export const GetTerminalOutputToolData: IToolData = { toolReferenceName: 'getTerminalOutput', legacyToolReferenceFullNames: ['runCommands/getTerminalOutput'], displayName: localize('getTerminalOutputTool.displayName', 'Get Terminal Output'), - modelDescription: `Get output from an active terminal execution (identified by the \`id\` returned from ${TerminalToolId.RunInTerminal}).`, + modelDescription: `Get output from an active terminal execution (identified by the \`id\` returned from ${TerminalToolId.RunInTerminal}). Use this to inspect output from a terminal started in async mode or a sync command that timed out and moved to the background. If a background command has not yet completed, you will be automatically notified when it finishes — do NOT poll; end your turn and wait.`, icon: Codicon.terminal, source: ToolDataSource.Internal, inputSchema: { @@ -49,6 +49,7 @@ interface IOutputSnapshot { export class GetTerminalOutputTool extends Disposable implements IToolImpl { private static readonly _maxOutputSnapshots = 100; + private static readonly _tailCharBudget = 8000; private readonly _lastOutputSnapshotByExecutionId = new Map(); constructor( @@ -101,7 +102,7 @@ export class GetTerminalOutputTool extends Disposable implements IToolImpl { private _formatOutput(id: string, terminalInstanceId: number, output: string): string { if (!this._configurationService.getValue(TerminalChatAgentToolsSettingId.OutputDeltas)) { this._lastOutputSnapshotByExecutionId.clear(); - return `Output of terminal ${id}:\n${output}`; + return this._formatTailOrFull(output, `Output of terminal ${id}`); } const previousOutputSnapshot = this._lastOutputSnapshotByExecutionId.get(id); @@ -109,17 +110,38 @@ export class GetTerminalOutputTool extends Disposable implements IToolImpl { this._rememberOutput(id, currentOutputSnapshot); if (previousOutputSnapshot === undefined) { - return `Output of terminal ${id}:\n${output}`; + return this._formatTailOrFull(output, `Output of terminal ${id}`); } if (currentOutputSnapshot.length === previousOutputSnapshot.length && currentOutputSnapshot.hash === previousOutputSnapshot.hash) { - return `Output of terminal ${id} unchanged since previous poll (${output.length} characters already shown). No new output.`; + return `Output of terminal ${id} unchanged since previous poll (${output.length} total characters in buffer). No new output.`; } if (output.length > previousOutputSnapshot.length && this._hashOutput(output, previousOutputSnapshot.length) === previousOutputSnapshot.hash) { const delta = output.slice(previousOutputSnapshot.length); return `Output of terminal ${id} since previous poll (${delta.length} new characters, ${output.length} total characters):\n${delta}`; } - return `Output of terminal ${id} changed since previous poll; returning current output (${output.length} characters):\n${output}`; + return this._formatTailOrFull(output, `Output of terminal ${id} changed since previous poll`); + } + + private _formatTailOrFull(output: string, prefix: string): string { + if (output.length <= GetTerminalOutputTool._tailCharBudget) { + return `${prefix}:\n${output}`; + } + const tail = this._tailOf(output, GetTerminalOutputTool._tailCharBudget); + const omitted = output.length - tail.length; + return `${prefix}; showing last ${tail.length} of ${output.length} characters (${omitted} earlier characters omitted). If you need the omitted earlier output, re-run the command and redirect output to a file, then read that file:\n${tail}`; + } + + private _tailOf(output: string, charBudget: number): string { + if (output.length <= charBudget) { + return output; + } + const startIndex = output.length - charBudget; + const newlineIndex = output.indexOf('\n', startIndex); + if (newlineIndex !== -1 && newlineIndex < output.length - 1) { + return output.slice(newlineIndex + 1); + } + return output.slice(startIndex); } private _rememberOutput(id: string, snapshot: IOutputSnapshot): void { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index acd1e474bd718..b312535151b0b 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -26,7 +26,7 @@ import { ILabelService } from '../../../../../../platform/label/common/label.js' import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { AgentSandboxSettingId } from '../../../../../../platform/sandbox/common/settings.js'; import { ICommandDetectionCapability, TerminalCapability } from '../../../../../../platform/terminal/common/capabilities/capabilities.js'; -import { ITerminalLogService, ITerminalProfile } from '../../../../../../platform/terminal/common/terminal.js'; +import { ITerminalLogService, ITerminalProfile, TerminalExitReason } from '../../../../../../platform/terminal/common/terminal.js'; import { IRemoteAgentService } from '../../../../../services/remote/common/remoteAgentService.js'; import { TerminalToolConfirmationStorageKeys } from '../../../../chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.js'; import { IChatService, ChatRequestQueueKind, ElicitationState, type IChatExternalToolInvocationUpdate, type IChatTerminalToolInvocationData } from '../../../../chat/common/chatService/chatService.js'; @@ -80,7 +80,7 @@ import { IOutputAnalyzer } from './outputAnalyzer.js'; import { SandboxOutputAnalyzer, outputLooksSandboxBlocked, outputLooksSandboxNetworkBlocked } from './sandboxOutputAnalyzer.js'; import { IAgentSessionsService } from '../../../../chat/browser/agentSessions/agentSessionsService.js'; import { ITerminalSandboxService, TerminalSandboxPrerequisiteCheck, type ITerminalSandboxPrecheckInputs, type ITerminalSandboxResolvedNetworkDomains } from '../../common/terminalSandboxService.js'; -import { LanguageModelPartAudience } from '../../../../chat/common/languageModels.js'; +import { ILanguageModelsService, LanguageModelPartAudience } from '../../../../chat/common/languageModels.js'; import { isSessionAutoApproveLevel, isTerminalAutoApproveAllowed, isToolEligibleForTerminalAutoApproval } from './terminalToolAutoApprove.js'; import type { IJSONSchemaMap } from '../../../../../../base/common/jsonSchema.js'; import { ChatElicitationRequestPart } from '../../../../chat/common/model/chatProgressTypes/chatElicitationRequestPart.js'; @@ -389,7 +389,7 @@ export async function createRunInTerminalToolData( }, timeout: { type: 'number', - description: 'Optional hard cap in milliseconds on how long the tool tracks the command before returning. Omit to let the command run to completion (recommended for package installs, builds, and long-running scripts). Use 0 to explicitly indicate no timeout.', + description: 'Optional. Hard cap in milliseconds before the tool returns. If you set a timeout, use a generous value (e.g. 600000 = 10 min for installs, 900000 = 15 min for big builds). Too-short timeouts cause the command to continue in the background, which wastes turns on unnecessary polling. Omit entirely to let the command run to completion. Use 0 to explicitly indicate no timeout.', }, }, required: ['command', 'explanation', 'goal', 'mode'] @@ -731,6 +731,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { @IInstantiationService private readonly _instantiationService: IInstantiationService, @ILabelService private readonly _labelService: ILabelService, @ILanguageModelToolsService private readonly _languageModelToolsService: ILanguageModelToolsService, + @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, @IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService, @IStorageService private readonly _storageService: IStorageService, @ITerminalChatService private readonly _terminalChatService: ITerminalChatService, @@ -2385,6 +2386,15 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { return lines.join('\n'); } + /** + * Resolves the `copilot/copilot-utility-small` model identifier for + * steering messages. Returns `undefined` if unavailable. + */ + private async _resolveUtilitySmallModelId(): Promise { + const models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', id: 'copilot-utility-small' }); + return models[0]; + } + private async _getOutputAnalyzerMessage(exitCode: number | undefined, exitResult: string, commandLine: string, isSandboxWrapped: boolean): Promise { for (const analyzer of this._outputAnalyzers) { const message = await analyzer.analyze({ exitCode, exitResult, commandLine, isSandboxWrapped }); @@ -2762,8 +2772,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { return; } - // Capture model/mode/tools from the last request so the steering message - // uses the same settings as the original conversation (not defaults). + // Capture mode/tools from the last request. The model is resolved + // separately via the utility-small alias (falling back to conversation model). const lastRequest = sessionRef.object.lastRequest; const sendOptions: { userSelectedModelId?: string; modeInfo?: IChatRequestModeInfo; userSelectedTools?: IObservable } = {}; if (lastRequest) { @@ -2774,6 +2784,25 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } } + // Resolve the utility-small alias eagerly and cache it so steering + // dispatch stays synchronous. Falls back to the conversation model if + // an event fires before resolution completes (rare — events require + // the terminal to produce output first). + let cachedUtilitySmallId: string | undefined; + this._resolveUtilitySmallModelId().then(utilitySmallId => { + cachedUtilitySmallId = utilitySmallId; + if (utilitySmallId) { + this._logService.debug(`RunInTerminalTool: Steering messages for background terminal ${termId} will use model '${utilitySmallId}'`); + } else { + this._logService.debug(`RunInTerminalTool: 'copilot/copilot-utility-small' alias unavailable; steering messages for background terminal ${termId} will use conversation model '${sendOptions.userSelectedModelId ?? ''}'`); + } + }, err => { + this._logService.warn(`RunInTerminalTool: Failed to resolve 'copilot/copilot-utility-small' alias for terminal ${termId}; steering messages will use conversation model`, err); + }); + const buildSendOptions = (): typeof sendOptions => { + return cachedUtilitySmallId ? { ...sendOptions, userSelectedModelId: cachedUtilitySmallId } : sendOptions; + }; + // Continue the output monitor in background mode for prompt-for-input detection. // The monitor wakes only on new terminal data (not on a fixed interval), so // resource cost is proportional to actual terminal activity. @@ -2890,7 +2919,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { this._logService.debug(`RunInTerminalTool: Input needed in background terminal ${termId}, notifying chat session`); this._chatService.sendRequest(chatSessionResource, message, { - ...sendOptions, + ...buildSendOptions(), queue: ChatRequestQueueKind.Steering, isSystemInitiated: true, systemInitiatedLabel: localize('terminalAssessingOutput', "{0} may need input", commandDisplay), @@ -2944,7 +2973,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { this._logService.debug(`RunInTerminalTool: Command completed in background terminal ${termId}, notifying chat session`); this._chatService.sendRequest(chatSessionResource, message, { - ...sendOptions, + ...buildSendOptions(), queue: ChatRequestQueueKind.Steering, isSystemInitiated: true, systemInitiatedLabel: localize('terminalCommandCompleted', "{0} completed", commandDisplay), @@ -3002,6 +3031,12 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { disposeNotification(); return; } + // Skip steering message when user manually closed the terminal (#317059). + if (terminalInstance.exitReason === TerminalExitReason.User) { + this._logService.debug(`RunInTerminalTool: Background terminal ${termId} closed by user, suppressing steering message`); + disposeNotification(); + return; + } if (handleSessionCancelled()) { return; } @@ -3012,7 +3047,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const message = `[Terminal ${termId} notification: terminal exited${exitCodeText}. The terminal process ended before the command could complete normally; further commands cannot be sent to this terminal ID.]\nTerminal output:\n${currentOutput}`; this._logService.debug(`RunInTerminalTool: Background terminal ${termId} disposed${exitCodeText}, notifying chat session`); this._chatService.sendRequest(chatSessionResource, message, { - ...sendOptions, + ...buildSendOptions(), queue: ChatRequestQueueKind.Steering, isSystemInitiated: true, systemInitiatedLabel: localize('terminalProcessExited', "{0} terminal exited", commandDisplay), diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/getTerminalOutputTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/getTerminalOutputTool.test.ts index 8fe1129da8e21..928e6a076376c 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/getTerminalOutputTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/getTerminalOutputTool.test.ts @@ -145,7 +145,7 @@ suite('GetTerminalOutputTool', () => { assert.strictEqual(result.content.length, 1); assert.strictEqual(result.content[0].kind, 'text'); const value = (result.content[0] as { value: string }).value; - assert.strictEqual(value, `Output of terminal ${KNOWN_TERMINAL_ID} unchanged since previous poll (11 characters already shown). No new output.`); + assert.strictEqual(value, `Output of terminal ${KNOWN_TERMINAL_ID} unchanged since previous poll (11 total characters in buffer). No new output.`); }); test('returns only new output when output deltas experiment is enabled', async () => { @@ -245,4 +245,63 @@ suite('GetTerminalOutputTool', () => { assert.ok(value.includes('changed since previous poll')); assert.ok(value.endsWith('\nnew screen')); }); + + test('returns only the tail on first poll when output exceeds the tail budget', async () => { + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.OutputDeltas, true); + const bigLine = 'x'.repeat(200); + const lines: string[] = []; + for (let i = 0; i < 100; i++) { + lines.push(`${i}-${bigLine}`); + } + const output = lines.join('\n'); + RunInTerminalTool.getExecution = () => createMockExecution(output); + + const result = await tool.invoke( + createInvocation(KNOWN_TERMINAL_ID), + async () => 0, + { report: () => { } }, + CancellationToken.None, + ); + + const value = (result.content[0] as { value: string }).value; + assert.ok(value.includes(`showing last `)); + assert.ok(value.includes(`of ${output.length} characters`)); + assert.ok(value.includes('earlier characters omitted')); + assert.ok(value.endsWith(`\n${lines[lines.length - 1]}`)); + assert.ok(value.length < output.length); + }); + + test('returns only the tail on non-prefix fallback when output exceeds the tail budget', async () => { + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.OutputDeltas, true); + const execution = createMutableMockExecution('seed'); + RunInTerminalTool.getExecution = () => execution; + + await tool.invoke( + createInvocation(KNOWN_TERMINAL_ID), + async () => 0, + { report: () => { } }, + CancellationToken.None, + ); + + const bigLine = 'y'.repeat(200); + const lines: string[] = []; + for (let i = 0; i < 100; i++) { + lines.push(`${i}-${bigLine}`); + } + const replaced = lines.join('\n'); + execution.setOutput(replaced); + + const result = await tool.invoke( + createInvocation(KNOWN_TERMINAL_ID), + async () => 0, + { report: () => { } }, + CancellationToken.None, + ); + + const value = (result.content[0] as { value: string }).value; + assert.ok(value.includes('changed since previous poll')); + assert.ok(value.includes(`of ${replaced.length} characters`)); + assert.ok(value.endsWith(`\n${lines[lines.length - 1]}`)); + assert.ok(value.length < replaced.length); + }); }); diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 6bca383c22cd1..eae70bdf806fb 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -205,6 +205,9 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi })); this._register(this.on('did-click-link', ({ uri }) => { + if (!this.isActiveElement()) { + return; + } this._onDidClickLink.fire(uri); })); @@ -714,8 +717,12 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi return event.isTrusted || !!this._content.options.forwardUntrustedKeypressEvents; } + private isActiveElement(): boolean { + return !!this.element && this.window?.document.activeElement === this.element; + } + private handleKeyEvent(type: 'keydown' | 'keyup', event: KeyEvent) { - if (!this.shouldForwardKeyEvent(event)) { + if (!this.shouldForwardKeyEvent(event) || !this.isActiveElement()) { return; } diff --git a/src/vs/workbench/services/authentication/browser/authenticationMcpAccessService.ts b/src/vs/workbench/services/authentication/browser/authenticationMcpAccessService.ts index d4ca947868d98..7fc1a9c6df848 100644 --- a/src/vs/workbench/services/authentication/browser/authenticationMcpAccessService.ts +++ b/src/vs/workbench/services/authentication/browser/authenticationMcpAccessService.ts @@ -10,6 +10,26 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta import { IProductService } from '../../../../platform/product/common/productService.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +/** + * Compares two MCP server URLs for the purpose of access binding. They are compared by their canonical + * WHATWG URL form, so cosmetic differences in the origin (host case, default port, encoding) and a root + * trailing slash ("foo.com" vs "foo.com/") don't force a spurious re-consent — while a trailing slash on + * a path ("foo.com/a" vs "foo.com/a/") is preserved as a meaningful difference between endpoints. + */ +export function urlsEqual(a: string | undefined, b: string | undefined): boolean { + if (a === b) { + return true; + } + if (a === undefined || b === undefined) { + return false; + } + try { + return new URL(a).toString() === new URL(b).toString(); + } catch { + return false; + } +} + export interface AllowedMcpServer { id: string; name: string; @@ -22,6 +42,12 @@ export interface AllowedMcpServer { lastUsed?: number; // If true, this comes from the product.json trusted?: boolean; + /** + * The MCP server URL the grant was made for. A token is only released to a server whose current + * URL matches this, so changing the URL while keeping the same id requires the user to re-consent. + * Undefined for stdio servers, which have no URL. + */ + url?: string; } export const IAuthenticationMcpAccessService = createDecorator('IAuthenticationMcpAccessService'); @@ -31,7 +57,9 @@ export interface IAuthenticationMcpAccessService { readonly onDidChangeMcpSessionAccess: Event<{ providerId: string; accountName: string }>; /** - * Check MCP server access to an account + * Inspect the stored access decision for an MCP server, keyed by id alone. Used by management and + * inspection surfaces (e.g. the "Manage Trusted MCP Servers" UI) that operate on a server id without + * a live URL. For the security-critical token-release gate use {@link isAccessAllowedForUrl} instead. * @param providerId The id of the authentication provider * @param accountName The account name that access is checked for * @param mcpServerId The id of the MCP server requesting access @@ -39,6 +67,19 @@ export interface IAuthenticationMcpAccessService { * if they haven't made a choice yet */ isAccessAllowed(providerId: string, accountName: string, mcpServerId: string): boolean | undefined; + /** + * Gate for releasing a token to an HTTP MCP server. Access is only allowed if {@link mcpServerUrl} + * matches the URL stored when access was granted, so re-pointing a server at a new endpoint (while + * keeping the same id) requires the user to re-consent. `product.json`-trusted servers bypass the + * URL check. Only HTTP servers authenticate, so the URL is always known and therefore required. + * @param providerId The id of the authentication provider + * @param accountName The account name that access is checked for + * @param mcpServerId The id of the MCP server requesting access + * @param mcpServerUrl The MCP server's current URL + * @returns Returns true or false if the user has opted to permanently grant or disallow access, and undefined + * if they haven't made a choice yet (or the URL no longer matches the granted one) + */ + isAccessAllowedForUrl(providerId: string, accountName: string, mcpServerId: string, mcpServerUrl: string): boolean | undefined; readAllowedMcpServers(providerId: string, accountName: string): AllowedMcpServer[]; updateAllowedMcpServers(providerId: string, accountName: string, mcpServers: AllowedMcpServer[]): void; removeAllowedMcpServers(providerId: string, accountName: string): void; @@ -59,6 +100,14 @@ export class AuthenticationMcpAccessService extends Disposable implements IAuthe } isAccessAllowed(providerId: string, accountName: string, mcpServerId: string): boolean | undefined { + return this._isAccessAllowed(providerId, accountName, mcpServerId, undefined); + } + + isAccessAllowedForUrl(providerId: string, accountName: string, mcpServerId: string, mcpServerUrl: string): boolean | undefined { + return this._isAccessAllowed(providerId, accountName, mcpServerId, mcpServerUrl); + } + + private _isAccessAllowed(providerId: string, accountName: string, mcpServerId: string, mcpServerUrl: string | undefined): boolean | undefined { const trustedMCPServerAuthAccess = this._productService.trustedMcpAuthAccess; if (Array.isArray(trustedMCPServerAuthAccess)) { if (trustedMCPServerAuthAccess.includes(mcpServerId)) { @@ -73,6 +122,11 @@ export class AuthenticationMcpAccessService extends Disposable implements IAuthe if (!mcpServerData) { return undefined; } + // A grant is bound to the URL it was made for: if the server now has a different URL, the user + // must re-consent before a token is released to it. + if (mcpServerUrl !== undefined && !urlsEqual(mcpServerData.url, mcpServerUrl)) { + return undefined; + } // This property didn't exist on this data previously, inclusion in the list at all indicates allowance return mcpServerData.allowed !== undefined ? mcpServerData.allowed @@ -131,6 +185,10 @@ export class AuthenticationMcpAccessService extends Disposable implements IAuthe if (mcpServer.name && mcpServer.name !== mcpServer.id && allowList[index].name !== mcpServer.name) { allowList[index].name = mcpServer.name; } + // Only overwrite the URL when one is provided, so management toggles (which omit it) keep the binding. + if (mcpServer.url !== undefined) { + allowList[index].url = mcpServer.url; + } } } diff --git a/src/vs/workbench/services/authentication/test/browser/authenticationMcpAccessService.test.ts b/src/vs/workbench/services/authentication/test/browser/authenticationMcpAccessService.test.ts index 8d7cdba0eb2d5..38071d9b6e350 100644 --- a/src/vs/workbench/services/authentication/test/browser/authenticationMcpAccessService.test.ts +++ b/src/vs/workbench/services/authentication/test/browser/authenticationMcpAccessService.test.ts @@ -120,6 +120,105 @@ suite('AuthenticationMcpAccessService', () => { }); }); + suite('isAccessAllowedForUrl URL binding (security)', () => { + const serverUrl = 'https://server.example.com/mcp'; + + test('grants access when the supplied URL matches the stored URL', () => { + authenticationMcpAccessService.updateAllowedMcpServers('github', 'user@example.com', [ + { id: 'http-server', name: 'HTTP Server', allowed: true, url: serverUrl } + ]); + + const result = authenticationMcpAccessService.isAccessAllowedForUrl('github', 'user@example.com', 'http-server', serverUrl); + assert.strictEqual(result, true); + }); + + test('grants access despite cosmetic origin differences (root slash, host case)', () => { + authenticationMcpAccessService.updateAllowedMcpServers('github', 'user@example.com', [ + { id: 'http-server', name: 'HTTP Server', allowed: true, url: 'https://server.example.com' } + ]); + + // "foo.com" and "foo.com/" are the same origin, and the host is case-insensitive. + assert.strictEqual( + authenticationMcpAccessService.isAccessAllowedForUrl('github', 'user@example.com', 'http-server', 'https://server.example.com/'), + true + ); + assert.strictEqual( + authenticationMcpAccessService.isAccessAllowedForUrl('github', 'user@example.com', 'http-server', 'https://SERVER.EXAMPLE.COM'), + true + ); + }); + + test('re-prompts when a path trailing slash differs (foo.com/a vs foo.com/a/)', () => { + authenticationMcpAccessService.updateAllowedMcpServers('github', 'user@example.com', [ + { id: 'http-server', name: 'HTTP Server', allowed: true, url: 'https://server.example.com/mcp' } + ]); + + // A trailing slash on a path points at a different endpoint, so the user must re-consent. + const result = authenticationMcpAccessService.isAccessAllowedForUrl('github', 'user@example.com', 'http-server', 'https://server.example.com/mcp/'); + assert.strictEqual(result, undefined); + }); + + test('re-prompts (returns undefined) when the URL changed for the same server id', () => { + authenticationMcpAccessService.updateAllowedMcpServers('github', 'user@example.com', [ + { id: 'http-server', name: 'HTTP Server', allowed: true, url: serverUrl } + ]); + + const result = authenticationMcpAccessService.isAccessAllowedForUrl('github', 'user@example.com', 'http-server', 'https://evil.example.com/mcp'); + assert.strictEqual(result, undefined); + }); + + test('breaks legacy grants that have no stored URL when a URL is supplied', () => { + // Legacy grant: stored before URL binding existed, so it has no URL. + authenticationMcpAccessService.updateAllowedMcpServers('github', 'user@example.com', [ + { id: 'http-server', name: 'HTTP Server', allowed: true } + ]); + + const result = authenticationMcpAccessService.isAccessAllowedForUrl('github', 'user@example.com', 'http-server', serverUrl); + assert.strictEqual(result, undefined); + }); + + test('inspection (isAccessAllowed) returns the stored decision regardless of stored URL', () => { + authenticationMcpAccessService.updateAllowedMcpServers('github', 'user@example.com', [ + { id: 'http-server', name: 'HTTP Server', allowed: true, url: serverUrl } + ]); + + const result = authenticationMcpAccessService.isAccessAllowed('github', 'user@example.com', 'http-server'); + assert.strictEqual(result, true); + }); + + test('stdio servers (inspection, no URL) are unaffected', () => { + authenticationMcpAccessService.updateAllowedMcpServers('github', 'user@example.com', [ + { id: 'stdio-server', name: 'Stdio Server', allowed: true } + ]); + + const result = authenticationMcpAccessService.isAccessAllowed('github', 'user@example.com', 'stdio-server'); + assert.strictEqual(result, true); + }); + + test('product.json trusted servers bypass the URL check', () => { + productService.trustedMcpAuthAccess = ['trusted-http-server']; + + const result = authenticationMcpAccessService.isAccessAllowedForUrl('github', 'user@example.com', 'trusted-http-server', 'https://anything.example.com/mcp'); + assert.strictEqual(result, true); + }); + + test('a management toggle that omits the URL does not clear the stored binding', () => { + authenticationMcpAccessService.updateAllowedMcpServers('github', 'user@example.com', [ + { id: 'http-server', name: 'HTTP Server', allowed: true, url: serverUrl } + ]); + + // Simulate the management UI toggling access without knowledge of the URL. + authenticationMcpAccessService.updateAllowedMcpServers('github', 'user@example.com', [ + { id: 'http-server', name: 'HTTP Server', allowed: true } + ]); + + const stored = authenticationMcpAccessService.readAllowedMcpServers('github', 'user@example.com') + .find(s => s.id === 'http-server'); + assert.strictEqual(stored?.url, serverUrl); + assert.strictEqual(authenticationMcpAccessService.isAccessAllowedForUrl('github', 'user@example.com', 'http-server', serverUrl), true); + }); + }); + suite('readAllowedMcpServers', () => { test('returns empty array when no data exists', () => { const result = authenticationMcpAccessService.readAllowedMcpServers('github', 'user@example.com'); diff --git a/src/vs/workbench/services/authentication/test/browser/authenticationQueryServiceMocks.ts b/src/vs/workbench/services/authentication/test/browser/authenticationQueryServiceMocks.ts index 9dc3c62ae1393..099207c0c4220 100644 --- a/src/vs/workbench/services/authentication/test/browser/authenticationQueryServiceMocks.ts +++ b/src/vs/workbench/services/authentication/test/browser/authenticationQueryServiceMocks.ts @@ -9,7 +9,7 @@ import { AuthenticationSession, AuthenticationSessionAccount, IAuthenticationPro import { IAuthenticationUsageService } from '../../browser/authenticationUsageService.js'; import { IAuthenticationMcpUsageService } from '../../browser/authenticationMcpUsageService.js'; import { IAuthenticationAccessService } from '../../browser/authenticationAccessService.js'; -import { IAuthenticationMcpAccessService } from '../../browser/authenticationMcpAccessService.js'; +import { IAuthenticationMcpAccessService, urlsEqual } from '../../browser/authenticationMcpAccessService.js'; import { IAuthenticationMcpService } from '../../browser/authenticationMcpService.js'; /** @@ -207,9 +207,25 @@ export class TestMcpAccessService extends BaseTestService implements IAuthentica isAccessAllowed(providerId: string, accountName: string, mcpServerId: string): boolean | undefined { this.trackCall('isAccessAllowed', providerId, accountName, mcpServerId); + return this._isAccessAllowed(providerId, accountName, mcpServerId, undefined); + } + + isAccessAllowedForUrl(providerId: string, accountName: string, mcpServerId: string, mcpServerUrl: string): boolean | undefined { + this.trackCall('isAccessAllowedForUrl', providerId, accountName, mcpServerId, mcpServerUrl); + return this._isAccessAllowed(providerId, accountName, mcpServerId, mcpServerUrl); + } + + private _isAccessAllowed(providerId: string, accountName: string, mcpServerId: string, mcpServerUrl: string | undefined): boolean | undefined { const servers = this.data.get(this.getKey(providerId, accountName)) || []; const server = servers.find((s: any) => s.id === mcpServerId); - return server?.allowed; + if (!server) { + return undefined; + } + if (mcpServerUrl !== undefined && !urlsEqual(server.url, mcpServerUrl)) { + return undefined; + } + // Matches production: presence in the list with an undefined `allowed` indicates allowance. + return server.allowed !== undefined ? server.allowed : true; } readAllowedMcpServers(providerId: string, accountName: string): any[] {