From 8ea0ece06f660ba8058382ebb4c35081f722d05a Mon Sep 17 00:00:00 2001 From: Elijah King Date: Mon, 20 Apr 2026 11:06:30 -0700 Subject: [PATCH 01/32] Reorganize onboarding 'Build with AI agents' step into Chat modes vs other surfaces --- .../browser/media/variationA.css | 36 ++++++++++-- .../browser/onboardingVariationA.ts | 55 +++++++++++-------- .../common/onboardingTypes.ts | 4 +- 3 files changed, 67 insertions(+), 28 deletions(-) diff --git a/src/vs/workbench/contrib/welcomeOnboarding/browser/media/variationA.css b/src/vs/workbench/contrib/welcomeOnboarding/browser/media/variationA.css index cacd6a0acc698..9fed80460c797 100644 --- a/src/vs/workbench/contrib/welcomeOnboarding/browser/media/variationA.css +++ b/src/vs/workbench/contrib/welcomeOnboarding/browser/media/variationA.css @@ -723,13 +723,40 @@ } .onboarding-a-sessions-features { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; + display: flex; + flex-direction: column; + gap: 14px; flex: 1; align-content: start; } +.onboarding-a-sessions-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.onboarding-a-sessions-group-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--vscode-descriptionForeground); +} + +.onboarding-a-sessions-grid { + display: grid; + gap: 10px; +} + +.onboarding-a-sessions-grid-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.onboarding-a-sessions-grid-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + .onboarding-a-feature-card { display: flex; align-items: flex-start; @@ -982,7 +1009,8 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } - .onboarding-a-sessions-features { + .onboarding-a-sessions-grid-3, + .onboarding-a-sessions-grid-2 { grid-template-columns: 1fr; } diff --git a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts index 7bea08f1ad062..87ddb0e44fd86 100644 --- a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts +++ b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts @@ -979,18 +979,13 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi ? ['\u2318', '\u2303', 'I'] // Cmd+Control+I : ['Ctrl', 'Alt', 'I']; const shortcut = keys.map(k => this._createKbd(k)); - el.append( - localize('onboarding.step.agentSessions.subtitle.before', "Tip: Press "), - ); + el.append(localize('onboarding.step.agentSessions.subtitle.before', "Open Chat anytime with ")); for (let i = 0; i < shortcut.length; i++) { if (i > 0) { - el.append(' + '); + el.append('+'); } el.append(shortcut[i]); } - el.append( - localize('onboarding.step.agentSessions.subtitle.after', " to open Chat"), - ); } private _renderAgentSessionsStep(container: HTMLElement): void { @@ -998,26 +993,42 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi const features = append(wrapper, $('.onboarding-a-sessions-features')); - this._createFeatureCard(features, Codicon.deviceDesktop, - localize('onboarding.sessions.local', "Local"), - localize('onboarding.sessions.local.desc', "Run agents interactively in the editor with full access to your workspace, tools, and terminal. Best for hands-on work where you want to review changes as they happen.")); + // Group 1: Chat modes — Ask / Agent / Plan + const chatGroup = append(features, $('.onboarding-a-sessions-group')); + const chatLabel = append(chatGroup, $('div.onboarding-a-sessions-group-label')); + chatLabel.textContent = localize('onboarding.sessions.group.chat', "In the Chat view"); + const chatGrid = append(chatGroup, $('.onboarding-a-sessions-grid.onboarding-a-sessions-grid-3')); + + this._createFeatureCard(chatGrid, Codicon.comment, + localize('onboarding.sessions.askMode', "Ask mode"), + localize('onboarding.sessions.askMode.desc', "Ask questions about your code or concepts and get answers with references \u2014 no changes to your files.")); + + this._createFeatureCard(chatGrid, Codicon.commentDiscussion, + localize('onboarding.sessions.agentMode', "Agent mode"), + localize('onboarding.sessions.agentMode.desc', "Describe a task and Copilot edits files, runs commands, and verifies the result. You review and approve each change.")); + + this._createFeatureCard(chatGrid, Codicon.listOrdered, + localize('onboarding.sessions.planMode', "Plan mode"), + localize('onboarding.sessions.planMode.desc', "Break a task into a step-by-step plan before any code changes. Review and refine it, then hand it off to be executed.")); - this._createFeatureCard(features, Codicon.cloud, - localize('onboarding.sessions.cloud', "Cloud"), - localize('onboarding.sessions.cloud.desc', "Delegate tasks to a cloud agent that creates a branch, implements changes, and opens a pull request. The agent continues working even if you close VS Code.")); + // Group 2: everything else + const moreGroup = append(features, $('.onboarding-a-sessions-group')); + const moreLabel = append(moreGroup, $('div.onboarding-a-sessions-group-label')); + moreLabel.textContent = localize('onboarding.sessions.group.more', "And beyond"); + const moreGrid = append(moreGroup, $('.onboarding-a-sessions-grid.onboarding-a-sessions-grid-2')); - this._createFeatureCard(features, Codicon.worktree, - localize('onboarding.sessions.worktree', "Copilot CLI"), - localize('onboarding.sessions.worktree.desc', "Run agents autonomously in an isolated worktree on your machine. Work on something else while the agent builds, tests, and iterates in the background.")); + this._createFeatureCard(moreGrid, Codicon.rocket, + localize('onboarding.sessions.runAnywhere', "Run anywhere"), + localize('onboarding.sessions.runAnywhere.desc', "Run agents locally for interactive work, in the background with Copilot CLI, or in the cloud to open a pull request your team can review.")); - const inlineDesc = this._createFeatureCard(features, Codicon.sparkle, - localize('onboarding.sessions.inline', "Inline Suggestions")); + const inlineDesc = this._createFeatureCard(moreGrid, Codicon.sparkle, + localize('onboarding.sessions.inline', "In the editor")); inlineDesc.append( - localize('onboarding.sessions.inline.desc1', "As you type, AI suggests completions and next edit predictions inline. Press "), + localize('onboarding.sessions.inline.desc1', "Completions and next-edit predictions appear as you type \u2014 press "), this._createKbd(localize('onboarding.sessions.inline.tab', "Tab")), - localize('onboarding.sessions.inline.desc2', " to accept or "), - this._createKbd(localize('onboarding.sessions.inline.esc', "Esc")), - localize('onboarding.sessions.inline.desc3', " to dismiss."), + localize('onboarding.sessions.inline.desc2', " to accept. Press "), + this._createKbd(isMacintosh ? '\u2318I' : 'Ctrl+I'), + localize('onboarding.sessions.inline.desc3', " for inline chat to make targeted edits without leaving the file."), ); // Tutorial link at bottom of content, above footer diff --git a/src/vs/workbench/contrib/welcomeOnboarding/common/onboardingTypes.ts b/src/vs/workbench/contrib/welcomeOnboarding/common/onboardingTypes.ts index 76d1d23c6a05c..1623dae4cee86 100644 --- a/src/vs/workbench/contrib/welcomeOnboarding/common/onboardingTypes.ts +++ b/src/vs/workbench/contrib/welcomeOnboarding/common/onboardingTypes.ts @@ -29,7 +29,7 @@ export function getOnboardingStepTitle(stepId: OnboardingStepId): string { case OnboardingStepId.AiPreference: return localize('onboarding.step.aiPreference', "Your AI Style"); case OnboardingStepId.AgentSessions: - return localize('onboarding.step.agentSessions', "Meet Your Agentic Coding Partner"); + return localize('onboarding.step.agentSessions', "Build with AI agents"); } } @@ -45,7 +45,7 @@ export function getOnboardingStepSubtitle(stepId: OnboardingStepId): string { case OnboardingStepId.AiPreference: return localize('onboarding.step.aiPreference.subtitle', "Choose how much AI collaboration fits your workflow"); case OnboardingStepId.AgentSessions: - return localize('onboarding.step.agentSessions.subtitle', "Tip: Press {0} to open Chat", isMacintosh ? '\u2318\u2303I' : 'Ctrl+Alt+I'); + return localize('onboarding.step.agentSessions.subtitle', "Open Chat anytime with {0}", isMacintosh ? '\u2318\u2303I' : 'Ctrl+Alt+I'); } } From 8f29f9e099db5ccd8eb8ac41824341daec50f7e4 Mon Sep 17 00:00:00 2001 From: Elijah King Date: Mon, 20 Apr 2026 15:41:35 -0700 Subject: [PATCH 02/32] Align onboarding 'Build with AI agents' wording with docs --- .../browser/onboardingVariationA.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts index 87ddb0e44fd86..a4f2889cb4f6c 100644 --- a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts +++ b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts @@ -999,36 +999,36 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi chatLabel.textContent = localize('onboarding.sessions.group.chat', "In the Chat view"); const chatGrid = append(chatGroup, $('.onboarding-a-sessions-grid.onboarding-a-sessions-grid-3')); - this._createFeatureCard(chatGrid, Codicon.comment, - localize('onboarding.sessions.askMode', "Ask mode"), - localize('onboarding.sessions.askMode.desc', "Ask questions about your code or concepts and get answers with references \u2014 no changes to your files.")); + this._createFeatureCard(chatGrid, Codicon.listOrdered, + localize('onboarding.sessions.planMode', "Plan"), + localize('onboarding.sessions.planMode.desc', "Produce a structured implementation plan before any code changes, then hand it off to an implementation agent to execute.")); this._createFeatureCard(chatGrid, Codicon.commentDiscussion, - localize('onboarding.sessions.agentMode', "Agent mode"), - localize('onboarding.sessions.agentMode.desc', "Describe a task and Copilot edits files, runs commands, and verifies the result. You review and approve each change.")); + localize('onboarding.sessions.agentMode', "Agent"), + localize('onboarding.sessions.agentMode.desc', "Describe a goal. The agent plans the approach, edits files, runs commands, and self-corrects \u2014 you review and approve along the way.")); - this._createFeatureCard(chatGrid, Codicon.listOrdered, - localize('onboarding.sessions.planMode', "Plan mode"), - localize('onboarding.sessions.planMode.desc', "Break a task into a step-by-step plan before any code changes. Review and refine it, then hand it off to be executed.")); + this._createFeatureCard(chatGrid, Codicon.comment, + localize('onboarding.sessions.askMode', "Ask"), + localize('onboarding.sessions.askMode.desc', "Ask questions about your code or technical concepts and get answers grounded in your codebase \u2014 no file changes.")); // Group 2: everything else const moreGroup = append(features, $('.onboarding-a-sessions-group')); const moreLabel = append(moreGroup, $('div.onboarding-a-sessions-group-label')); - moreLabel.textContent = localize('onboarding.sessions.group.more', "And beyond"); + moreLabel.textContent = localize('onboarding.sessions.group.more', "Beyond the Chat view"); const moreGrid = append(moreGroup, $('.onboarding-a-sessions-grid.onboarding-a-sessions-grid-2')); this._createFeatureCard(moreGrid, Codicon.rocket, - localize('onboarding.sessions.runAnywhere', "Run anywhere"), - localize('onboarding.sessions.runAnywhere.desc', "Run agents locally for interactive work, in the background with Copilot CLI, or in the cloud to open a pull request your team can review.")); + localize('onboarding.sessions.runAnywhere', "Run agents anywhere"), + localize('onboarding.sessions.runAnywhere.desc', "Run agents locally for interactive work, in the background with Copilot CLI, or in the cloud with cloud agents that open a pull request your team can review.")); const inlineDesc = this._createFeatureCard(moreGrid, Codicon.sparkle, - localize('onboarding.sessions.inline', "In the editor")); + localize('onboarding.sessions.inline', "AI assistance as you type")); inlineDesc.append( - localize('onboarding.sessions.inline.desc1', "Completions and next-edit predictions appear as you type \u2014 press "), + localize('onboarding.sessions.inline.desc1', "Inline suggestions and next edit predictions appear as you code \u2014 press "), this._createKbd(localize('onboarding.sessions.inline.tab', "Tab")), localize('onboarding.sessions.inline.desc2', " to accept. Press "), this._createKbd(isMacintosh ? '\u2318I' : 'Ctrl+I'), - localize('onboarding.sessions.inline.desc3', " for inline chat to make targeted edits without leaving the file."), + localize('onboarding.sessions.inline.desc3', " to open inline chat for targeted edits without leaving the editor."), ); // Tutorial link at bottom of content, above footer From 8d36495b66d35f6d7735e4fc4a7f926383387378 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Wed, 22 Apr 2026 19:54:56 +0200 Subject: [PATCH 03/32] Improves source map support --- build/rspack/package-lock.json | 47 ++++++++++++++++++- build/rspack/package.json | 3 +- build/rspack/rspack.serve-out.config.mts | 19 ++++++++ .../browser/componentFixtures/fixtureUtils.ts | 19 ++++++++ 4 files changed, 86 insertions(+), 2 deletions(-) diff --git a/build/rspack/package-lock.json b/build/rspack/package-lock.json index ea516c11a5fc2..e287b2c50024f 100644 --- a/build/rspack/package-lock.json +++ b/build/rspack/package-lock.json @@ -15,7 +15,8 @@ "devDependencies": { "@rspack/cli": "^1.3.18", "@rspack/core": "^1.3.18", - "@vscode/esm-url-webpack-plugin": "^1.0.1-3" + "@vscode/esm-url-webpack-plugin": "^1.0.1-3", + "source-map-loader": "^5.0.0" } }, "node_modules/@discoveryjs/json-ext": { @@ -3753,6 +3754,50 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-loader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-5.0.0.tgz", + "integrity": "sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.72.1" + } + }, + "node_modules/source-map-loader/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", diff --git a/build/rspack/package.json b/build/rspack/package.json index 82e74278001df..c9f5adb4dc150 100644 --- a/build/rspack/package.json +++ b/build/rspack/package.json @@ -9,7 +9,8 @@ "devDependencies": { "@rspack/cli": "^1.3.18", "@rspack/core": "^1.3.18", - "@vscode/esm-url-webpack-plugin": "^1.0.1-3" + "@vscode/esm-url-webpack-plugin": "^1.0.1-3", + "source-map-loader": "^5.0.0" }, "dependencies": { "@vscode/component-explorer": "^0.2.1-17", diff --git a/build/rspack/rspack.serve-out.config.mts b/build/rspack/rspack.serve-out.config.mts index 8e5c1ec41a98b..a05fe09d245e8 100644 --- a/build/rspack/rspack.serve-out.config.mts +++ b/build/rspack/rspack.serve-out.config.mts @@ -28,6 +28,7 @@ export default { context: repoRoot, mode: 'development', target: 'web', + devtool: 'source-map', entry: { workbench: path.join(repoRoot, 'out', 'vs', 'code', 'browser', 'workbench', 'workbench.js'), }, @@ -38,12 +39,30 @@ export default { assetModuleFilename: 'bundled/assets/[name][ext][query]', publicPath: '/', clean: true, + devtoolModuleFilenameTemplate: (info: { absoluteResourcePath: string }) => { + return `file:///${info.absoluteResourcePath.replace(/\\/g, '/')}`; + }, + }, + resolve: { + fallback: { + path: path.resolve(repoRoot, 'node_modules', 'path-browserify'), + fs: false, + module: false, + }, + }, + resolveLoader: { + modules: [path.join(__dirname, 'node_modules'), 'node_modules'], }, experiments: { css: true, }, module: { rules: [ + { + test: /\.js$/, + enforce: 'pre', + use: ['source-map-loader'], + }, { test: /\.css$/, type: 'css', diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts index c0be8d3ce37d0..9c51caa69f63c 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -109,6 +109,25 @@ import '../../../../platform/theme/common/colors/listColors.js'; import '../../../../platform/theme/common/colors/miscColors.js'; import '../../../common/theme.js'; +// eslint-disable-next-line local/code-import-patterns +import sourceMapSupport from 'source-map-support'; +sourceMapSupport.install({ + environment: 'browser', + handleUncaughtExceptions: false, + retrieveSourceMap: (source: string) => { + const mapUrl = source + '.map'; + try { + const xhr = new XMLHttpRequest(); + xhr.open('GET', mapUrl, false); + xhr.send(); + if (xhr.status === 200) { + return { url: null as never, map: xhr.responseText }; + } + } catch { } + return null; + }, +}); + /** * A storage service that never stores anything and always returns the default/fallback value. * This is useful for fixtures where we want consistent behavior without persisted state. From 6e0431265039501efb4e56c98db0577835563307 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Wed, 22 Apr 2026 19:55:25 +0200 Subject: [PATCH 04/32] Add detailed diff output for blocks-ci screenshot mismatch errors --- .github/workflows/screenshot-test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/screenshot-test.yml b/.github/workflows/screenshot-test.yml index 430d8c9f03a14..6354e498a4dfa 100644 --- a/.github/workflows/screenshot-test.yml +++ b/.github/workflows/screenshot-test.yml @@ -223,6 +223,9 @@ jobs: if: steps.blocks-ci.outputs.match == 'false' run: | echo "::error::blocks-ci screenshot hashes do not match committed file. See PR comment for updated content." + echo "" + echo "Diff between committed and expected blocks-ci-screenshots.md:" + diff -u test/componentFixtures/blocks-ci-screenshots.md /tmp/blocks-ci-updated.md || true exit 1 # - name: Compare screenshots From 715a174e5457fe302a277b48f943094a0b902086 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Wed, 22 Apr 2026 19:57:52 +0200 Subject: [PATCH 05/32] Improved time travel scheduler for fixtures to avoid scroll bar flickering Co-authored-by: Copilot --- .../base/test/common/timeTravelScheduler.ts | 320 ++++++++++++------ .../browser/componentFixtures/fixtureUtils.ts | 15 +- 2 files changed, 226 insertions(+), 109 deletions(-) diff --git a/src/vs/base/test/common/timeTravelScheduler.ts b/src/vs/base/test/common/timeTravelScheduler.ts index 117cf4e60c8dd..673669732f60f 100644 --- a/src/vs/base/test/common/timeTravelScheduler.ts +++ b/src/vs/base/test/common/timeTravelScheduler.ts @@ -18,6 +18,7 @@ export interface Scheduler { export interface ScheduledTask { readonly time: TimeOffset; readonly source: ScheduledTaskSource; + readonly useRealAnimationFrame?: boolean; run(): void; } @@ -27,6 +28,18 @@ export interface ScheduledTaskSource { readonly stackTrace: string | undefined; } +export interface TimeApi { + setTimeout(handler: TimerHandler, timeout?: number): any; + clearTimeout(id: any): void; + setInterval(handler: TimerHandler, interval: number): any; + clearInterval(id: any): void; + setImmediate?: ((handler: () => void) => any); + clearImmediate?: ((id: any) => void); + requestAnimationFrame?: ((callback: (time: number) => void) => number); + cancelAnimationFrame?: ((id: number) => void); + Date: DateConstructor; +} + interface ExtendedScheduledTask extends ScheduledTask { id: number; } @@ -66,6 +79,10 @@ export class TimeTravelScheduler implements Scheduler { return this.queue.length > 0; } + peekNext(): ScheduledTask | undefined { + return this.queue.getMin(); + } + getScheduledTasks(): readonly ScheduledTask[] { return this.queue.toSortedArray(); } @@ -80,8 +97,8 @@ export class TimeTravelScheduler implements Scheduler { return task; } - installGlobally(): IDisposable { - return overwriteGlobals(this); + installGlobally(options?: CreateVirtualTimeApiOptions): IDisposable { + return overwriteGlobalTimeApi(createVirtualTimeApi(this, options)); } } @@ -92,6 +109,7 @@ export class AsyncSchedulerProcessor extends Disposable { private readonly maxTaskCount: number; private readonly useSetImmediate: boolean; + private readonly _realTimeApi: TimeApi; private readonly queueEmptyEmitter = new Emitter(); public readonly onTaskQueueEmpty = this.queueEmptyEmitter.event; @@ -99,11 +117,12 @@ export class AsyncSchedulerProcessor extends Disposable { private lastError: Error | undefined; private _virtualDeadline = Number.MAX_SAFE_INTEGER; - constructor(private readonly scheduler: TimeTravelScheduler, options?: { useSetImmediate?: boolean; maxTaskCount?: number }) { + constructor(private readonly scheduler: TimeTravelScheduler, options?: { useSetImmediate?: boolean; maxTaskCount?: number; realTimeApi?: TimeApi }) { super(); this.maxTaskCount = options && options.maxTaskCount ? options.maxTaskCount : 100; this.useSetImmediate = options && options.useSetImmediate ? options.useSetImmediate : false; + this._realTimeApi = options?.realTimeApi ?? originalGlobalValues; this._register(scheduler.onTaskScheduled(() => { if (this.isProcessing) { @@ -119,12 +138,18 @@ export class AsyncSchedulerProcessor extends Disposable { // This allows promises created by a previous task to settle and schedule tasks before the next task is run. // Tasks scheduled in those promises might have to run before the current next task. Promise.resolve().then(() => { - if (this.useSetImmediate) { - originalGlobalValues.setImmediate(() => this._process()); + // When the next task requires a real animation frame (e.g. virtual rAF), + // use the real browser rAF so the browser reflows before the callback runs. + // This ensures DOM measurements like offsetHeight return accurate values. + const nextTask = this.scheduler.peekNext(); + if (nextTask?.useRealAnimationFrame && this._realTimeApi.requestAnimationFrame) { + this._realTimeApi.requestAnimationFrame(() => this._process()); + } else if (this.useSetImmediate && this._realTimeApi.setImmediate) { + this._realTimeApi.setImmediate(() => this._process()); } else if (setTimeout0IsFaster) { setTimeout0(() => this._process()); } else { - originalGlobalValues.setTimeout(() => this._process()); + this._realTimeApi.setTimeout(() => this._process()); } }); } @@ -217,146 +242,226 @@ export async function runWithFakedTimers(options: { startTime?: number; useFa return result; } -export const originalGlobalValues = { - setTimeout: globalThis.setTimeout.bind(globalThis), - clearTimeout: globalThis.clearTimeout.bind(globalThis), - setInterval: globalThis.setInterval.bind(globalThis), - clearInterval: globalThis.clearInterval.bind(globalThis), - setImmediate: globalThis.setImmediate?.bind(globalThis), - clearImmediate: globalThis.clearImmediate?.bind(globalThis), - requestAnimationFrame: globalThis.requestAnimationFrame?.bind(globalThis), - cancelAnimationFrame: globalThis.cancelAnimationFrame?.bind(globalThis), - Date: globalThis.Date, -}; - -function setTimeout(scheduler: Scheduler, handler: TimerHandler, timeout: number = 0): IDisposable { - if (typeof handler === 'string') { - throw new Error('String handler args should not be used and are not supported'); - } - - return scheduler.schedule({ - time: scheduler.now + timeout, - run: () => { - handler(); - }, - source: { - toString() { return 'setTimeout'; }, - stackTrace: new Error().stack, - } - }); +export function captureGlobalTimeApi(): TimeApi { + return { + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + setInterval: globalThis.setInterval.bind(globalThis), + clearInterval: globalThis.clearInterval.bind(globalThis), + setImmediate: globalThis.setImmediate?.bind(globalThis), + clearImmediate: globalThis.clearImmediate?.bind(globalThis), + requestAnimationFrame: globalThis.requestAnimationFrame?.bind(globalThis), + cancelAnimationFrame: globalThis.cancelAnimationFrame?.bind(globalThis), + Date: globalThis.Date, + }; } -function setInterval(scheduler: Scheduler, handler: TimerHandler, interval: number): IDisposable { - if (typeof handler === 'string') { - throw new Error('String handler args should not be used and are not supported'); - } - const validatedHandler = handler; - - let iterCount = 0; - const stackTrace = new Error().stack; +export const originalGlobalValues: TimeApi = captureGlobalTimeApi(); +// Expose the real setTimeout for the component explorer runtime, which needs true time +// even when virtual time is installed for fixtures. +// eslint-disable-next-line local/code-no-any-casts +(window as any).setTimeout_original = originalGlobalValues.setTimeout; - let disposed = false; - let lastDisposable: IDisposable; +export interface CreateVirtualTimeApiOptions { + fakeRequestAnimationFrame?: boolean; +} - function schedule(): void { - iterCount++; - const curIter = iterCount; - lastDisposable = scheduler.schedule({ - time: scheduler.now + interval, - run() { - if (!disposed) { - schedule(); - validatedHandler(); - } - }, +export function createVirtualTimeApi(scheduler: Scheduler, options?: CreateVirtualTimeApiOptions): TimeApi { + function virtualSetTimeout(handler: TimerHandler, timeout: number = 0): IDisposable { + if (typeof handler === 'string') { + throw new Error('String handler args should not be used and are not supported'); + } + return scheduler.schedule({ + time: scheduler.now + timeout, + run: () => { handler(); }, source: { - toString() { return `setInterval (iteration ${curIter})`; }, - stackTrace, + toString() { return 'setTimeout'; }, + stackTrace: new Error().stack, } }); } - schedule(); - - return { - dispose: () => { - if (disposed) { - return; - } - disposed = true; - lastDisposable.dispose(); - } - }; -} - -function overwriteGlobals(scheduler: Scheduler): IDisposable { - // eslint-disable-next-line local/code-no-any-casts - globalThis.setTimeout = ((handler: TimerHandler, timeout?: number) => setTimeout(scheduler, handler, timeout)) as any; - globalThis.clearTimeout = (timeoutId: any) => { + function virtualClearTimeout(timeoutId: unknown): void { if (typeof timeoutId === 'object' && timeoutId && 'dispose' in timeoutId) { - timeoutId.dispose(); - } else { - originalGlobalValues.clearTimeout(timeoutId); + (timeoutId as IDisposable).dispose(); } - }; + } - // eslint-disable-next-line local/code-no-any-casts - globalThis.setInterval = ((handler: TimerHandler, timeout: number) => setInterval(scheduler, handler, timeout)) as any; - globalThis.clearInterval = (timeoutId: any) => { - if (typeof timeoutId === 'object' && timeoutId && 'dispose' in timeoutId) { - timeoutId.dispose(); - } else { - originalGlobalValues.clearInterval(timeoutId); + function virtualSetInterval(handler: TimerHandler, interval: number): IDisposable { + if (typeof handler === 'string') { + throw new Error('String handler args should not be used and are not supported'); } - }; + const validatedHandler = handler; + let iterCount = 0; + const stackTrace = new Error().stack; + let disposed = false; + let lastDisposable: IDisposable; + + function schedule(): void { + iterCount++; + const curIter = iterCount; + lastDisposable = scheduler.schedule({ + time: scheduler.now + interval, + run() { + if (!disposed) { + schedule(); + validatedHandler(); + } + }, + source: { + toString() { return `setInterval (iteration ${curIter})`; }, + stackTrace, + } + }); + } + schedule(); - globalThis.Date = createDateClass(scheduler); + return { + dispose: () => { + if (disposed) { return; } + disposed = true; + lastDisposable.dispose(); + } + }; + } - return { - dispose: () => { - Object.assign(globalThis, originalGlobalValues); + function virtualClearInterval(intervalId: unknown): void { + if (typeof intervalId === 'object' && intervalId && 'dispose' in intervalId) { + (intervalId as IDisposable).dispose(); } - }; -} - -function createDateClass(scheduler: Scheduler): DateConstructor { - const OriginalDate = originalGlobalValues.Date; + } + const OriginalDate = globalThis.Date; function SchedulerDate(this: any, ...args: any): any { - // the Date constructor called as a function, ref Ecma-262 Edition 5.1, section 15.9.2. - // This remains so in the 10th edition of 2019 as well. if (!(this instanceof SchedulerDate)) { return new OriginalDate(scheduler.now).toString(); } - - // if Date is called as a constructor with 'new' keyword if (args.length === 0) { return new OriginalDate(scheduler.now); } // eslint-disable-next-line local/code-no-any-casts return new (OriginalDate as any)(...args); } - for (const prop in OriginalDate) { if (OriginalDate.hasOwnProperty(prop)) { // eslint-disable-next-line local/code-no-any-casts (SchedulerDate as any)[prop] = (OriginalDate as any)[prop]; } } - - SchedulerDate.now = function now() { - return scheduler.now; - }; - SchedulerDate.toString = function toString() { - return OriginalDate.toString(); - }; + SchedulerDate.now = function now() { return scheduler.now; }; + SchedulerDate.toString = function toString() { return OriginalDate.toString(); }; SchedulerDate.prototype = OriginalDate.prototype; SchedulerDate.parse = OriginalDate.parse; SchedulerDate.UTC = OriginalDate.UTC; SchedulerDate.prototype.toUTCString = OriginalDate.prototype.toUTCString; + /* eslint-disable local/code-no-any-casts */ + const api: TimeApi = { + setTimeout: virtualSetTimeout as any, + clearTimeout: virtualClearTimeout as any, + setInterval: virtualSetInterval as any, + clearInterval: virtualClearInterval as any, + Date: SchedulerDate as any, + }; + /* eslint-enable local/code-no-any-casts */ + + if (options?.fakeRequestAnimationFrame) { + let rafIdCounter = 0; + const rafDisposables = new Map(); + + api.requestAnimationFrame = (callback: (time: number) => void) => { + const id = ++rafIdCounter; + // Advance virtual time by 16ms (~60fps). The task is marked with + // useRealAnimationFrame so the AsyncSchedulerProcessor uses a real + // browser rAF to schedule its execution, ensuring the browser + // reflows before the callback runs (so DOM measurements like + // offsetHeight return accurate values). + const disposable = scheduler.schedule({ + time: scheduler.now + 16, + useRealAnimationFrame: true, + run: () => { + rafDisposables.delete(id); + callback(scheduler.now); + }, + source: { + toString() { return 'requestAnimationFrame'; }, + stackTrace: new Error().stack, + } + }); + rafDisposables.set(id, disposable); + return id; + }; + + api.cancelAnimationFrame = (id: number) => { + const disposable = rafDisposables.get(id); + if (disposable) { + disposable.dispose(); + rafDisposables.delete(id); + } + }; + } + + return api; +} + +export function overwriteGlobalTimeApi(api: TimeApi): IDisposable { + const captured = captureGlobalTimeApi(); + + // eslint-disable-next-line local/code-no-any-casts + globalThis.setTimeout = api.setTimeout as any; // eslint-disable-next-line local/code-no-any-casts - return SchedulerDate as any; + globalThis.clearTimeout = api.clearTimeout as any; + // eslint-disable-next-line local/code-no-any-casts + globalThis.setInterval = api.setInterval as any; + // eslint-disable-next-line local/code-no-any-casts + globalThis.clearInterval = api.clearInterval as any; + globalThis.Date = api.Date; + + if (api.requestAnimationFrame) { + globalThis.requestAnimationFrame = api.requestAnimationFrame; + } + if (api.cancelAnimationFrame) { + globalThis.cancelAnimationFrame = api.cancelAnimationFrame; + } + + return { + dispose: () => { + Object.assign(globalThis, captured); + } + }; +} + +export function createLoggingTimeApi( + underlying: TimeApi, + onCall: (name: string, stack: string | undefined, handler?: TimerHandler) => void, +): TimeApi { + return { + setTimeout(handler: TimerHandler, timeout?: number) { + onCall('setTimeout', new Error().stack, handler); + return underlying.setTimeout(handler, timeout); + }, + clearTimeout(id: unknown) { + return underlying.clearTimeout(id); + }, + setInterval(handler: TimerHandler, interval: number) { + onCall('setInterval', new Error().stack, handler); + return underlying.setInterval(handler, interval); + }, + clearInterval(id: unknown) { + return underlying.clearInterval(id); + }, + setImmediate: underlying.setImmediate ? (handler: () => void) => { + onCall('setImmediate', new Error().stack, handler); + return underlying.setImmediate!(handler); + } : undefined, + clearImmediate: underlying.clearImmediate, + requestAnimationFrame: underlying.requestAnimationFrame ? (callback: (time: number) => void) => { + onCall('requestAnimationFrame', new Error().stack, callback as TimerHandler); + return underlying.requestAnimationFrame!(callback); + } : undefined, + cancelAnimationFrame: underlying.cancelAnimationFrame, + Date: underlying.Date, + }; } interface PriorityQueue { @@ -365,6 +470,7 @@ interface PriorityQueue { remove(value: T): void; removeMin(): T | undefined; + getMin(): T | undefined; toSortedArray(): T[]; } diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts index 9c51caa69f63c..cabbc1cdcf710 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -102,7 +102,7 @@ import './fixtures.css'; // Import color registrations to ensure colors are available import { IdleDeadline, installFakeRunWhenIdle } from '../../../../base/common/async.js'; -import { AsyncSchedulerProcessor, TimeTravelScheduler } from '../../../../base/test/common/timeTravelScheduler.js'; +import { AsyncSchedulerProcessor, TimeTravelScheduler, captureGlobalTimeApi, createLoggingTimeApi, createVirtualTimeApi, overwriteGlobalTimeApi } from '../../../../base/test/common/timeTravelScheduler.js'; import '../../../../platform/theme/common/colors/baseColors.js'; import '../../../../platform/theme/common/colors/editorColors.js'; import '../../../../platform/theme/common/colors/listColors.js'; @@ -649,6 +649,15 @@ export interface ComponentFixtureOptions { type ThemedFixtures = ReturnType; +// Permanent logging layer that detects real timer API usage. +// Includes handler source for identification since bundled stack traces are not useful. +const realTimeApi = captureGlobalTimeApi(); +const loggingTimeApi = createLoggingTimeApi(realTimeApi, (name, stack, handler) => { + const handlerStr = typeof handler === 'function' ? handler.toString().slice(0, 500) : String(handler); + console.warn(`[ComponentFixture] Real ${name} called outside of virtual time.\nHandler: ${handlerStr}\nStack: ${stack}`); +}); +overwriteGlobalTimeApi(loggingTimeApi); + /** * Creates Dark and Light fixture variants from a single render function. * The render function receives a context with container and disposableStore. @@ -669,13 +678,15 @@ export function defineComponentFixture(options: ComponentFixtureOptions): Themed const scheduler = new TimeTravelScheduler(Date.now()); const p = schedulerStore.add(new AsyncSchedulerProcessor(scheduler, { maxTaskCount: 100, + realTimeApi, })); async function actualRender() { setupTheme(container, theme); - schedulerStore.add(scheduler.installGlobally()); + const virtualTimeApi = createVirtualTimeApi(scheduler, { fakeRequestAnimationFrame: true }); + schedulerStore.add(overwriteGlobalTimeApi(virtualTimeApi)); disposableStore.add(installFakeRunWhenIdle((_targetWindow, callback, _timeout?) => { return scheduler.schedule({ time: scheduler.now, From 021452aeb04d5e876f9dea851a9ba696e8d32626 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Wed, 22 Apr 2026 19:59:33 +0200 Subject: [PATCH 06/32] updates source-map-support --- package-lock.json | 44 ++++++++++++++++++-------------------------- package.json | 3 ++- 2 files changed, 20 insertions(+), 27 deletions(-) diff --git a/package-lock.json b/package-lock.json index ef94cb0371075..e4eed771d1da5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,6 +84,7 @@ "@types/node": "^22.18.10", "@types/sinon": "^10.0.2", "@types/sinon-test": "^2.4.2", + "@types/source-map-support": "^0.5.10", "@types/ssh2": "^1.15.4", "@types/trusted-types": "^2.0.7", "@types/vscode-notebook-renderer": "^1.72.0", @@ -159,7 +160,7 @@ "sinon": "^12.0.1", "sinon-test": "^3.1.3", "source-map": "0.6.1", - "source-map-support": "^0.3.2", + "source-map-support": "^0.5.21", "tar": "^7.5.9", "tsec": "0.2.7", "tslib": "^2.6.3", @@ -2875,6 +2876,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/source-map-support": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@types/source-map-support/-/source-map-support-0.5.10.tgz", + "integrity": "sha512-tgVP2H469x9zq34Z0m/fgPewGhg/MLClalNOiPIzQlXrSS2YrKu/xCdSCKnEDwkFha51VKEKB6A9wW26/ZNwzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "source-map": "^0.6.0" + } + }, "node_modules/@types/ssh2": { "version": "1.15.5", "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", @@ -4654,15 +4665,6 @@ "dev": true, "license": "MIT" }, - "node_modules/amdefine": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", - "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU= sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg==", - "dev": true, - "engines": { - "node": ">=0.4.2" - } - }, "node_modules/ansi-colors": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.3.tgz", @@ -17707,24 +17709,14 @@ } }, "node_modules/source-map-support": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.3.3.tgz", - "integrity": "sha1-NJAJd9W6PwfHdX7nLnO7GptTdU8= sha512-9O4+y9n64RewmFoKUZ/5Tx9IHIcXM6Q+RTSw6ehnqybUz4a7iwR3Eaw80uLtqqQ5D0C+5H03D4KKGo9PdP33Gg==", - "dev": true, - "dependencies": { - "source-map": "0.1.32" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.1.32", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.32.tgz", - "integrity": "sha1-yLbBZ3l7pHQKjqMyUhYv8IWRsmY= sha512-htQyLrrRLkQ87Zfrir4/yN+vAUd6DNjVayEjTSHXu29AYQJw57I4/xEL/M6p6E/woPNJwvZt6rVlzc7gFEJccQ==", + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, + "license": "MIT", "dependencies": { - "amdefine": ">=0.0.4" - }, - "engines": { - "node": ">=0.8.0" + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" } }, "node_modules/source-map-url": { diff --git a/package.json b/package.json index 1488774c81373..bfd499b27e878 100644 --- a/package.json +++ b/package.json @@ -238,7 +238,8 @@ "sinon": "^12.0.1", "sinon-test": "^3.1.3", "source-map": "0.6.1", - "source-map-support": "^0.3.2", + "source-map-support": "^0.5.21", + "@types/source-map-support": "^0.5.10", "tar": "^7.5.9", "tsec": "0.2.7", "tslib": "^2.6.3", From b781308a63ca8ebc45349b63b45267d702f65ed8 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Wed, 22 Apr 2026 20:10:35 +0200 Subject: [PATCH 07/32] fixes component fixture endless loop --- .../chat/browser/aiCustomization/aiCustomizationListWidget.ts | 4 +++- .../contrib/chat/browser/aiCustomization/mcpListWidget.ts | 4 +++- .../contrib/chat/browser/aiCustomization/pluginListWidget.ts | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index fb72fd4e6cb02..3514479e362ca 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -1499,8 +1499,10 @@ export class AICustomizationListWidget extends Disposable { // When offsetHeight returns 0 the container just became visible // after display:none and the browser hasn't reflowed yet — defer // layout to the next frame so measurements are accurate. + // Skip the retry when the element is hidden (display:none parent) + // since rAF will never produce a non-zero measurement. const searchBarHeight = this.searchAndButtonContainer.offsetHeight; - if (searchBarHeight === 0) { + if (searchBarHeight === 0 && this.element.offsetParent !== null) { DOM.getWindow(this.element).requestAnimationFrame(() => this.layout(height, width)); return; } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 10d6b3a41e9c4..5a23afd396ae5 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -956,8 +956,10 @@ export class McpListWidget extends Disposable { // When offsetHeight returns 0 the container just became visible // after display:none and the browser hasn't reflowed yet — defer // layout to the next frame so measurements are accurate. + // Skip the retry when the element is hidden (display:none parent) + // since rAF will never produce a non-zero measurement. const searchBarHeight = this.searchAndButtonContainer.offsetHeight; - if (searchBarHeight === 0) { + if (searchBarHeight === 0 && this.element.offsetParent !== null) { DOM.getWindow(this.element).requestAnimationFrame(() => this.layout(this.lastHeight, this.lastWidth)); return; } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts index 1825b7ea72de8..f82a81076993c 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts @@ -849,8 +849,10 @@ export class PluginListWidget extends Disposable { // When offsetHeight returns 0 the container just became visible // after display:none and the browser hasn't reflowed yet — defer // layout to the next frame so measurements are accurate. + // Skip the retry when the element is hidden (display:none parent) + // since rAF will never produce a non-zero measurement. const searchBarHeight = this.searchAndButtonContainer.offsetHeight; - if (searchBarHeight === 0) { + if (searchBarHeight === 0 && this.element.offsetParent !== null) { DOM.getWindow(this.element).requestAnimationFrame(() => this.layout(this.lastHeight, this.lastWidth)); return; } From aa1b5f6cadb28a327699be5564b799d1402e0dd8 Mon Sep 17 00:00:00 2001 From: Elijah King Date: Wed, 22 Apr 2026 13:38:25 -0700 Subject: [PATCH 08/32] Refine 'Build with AI Agents' onboarding step and re-enable by default - Replace Ask card group with 'Choose Your Agent' (Plan/Agent/Ask) and 'Agents That Work Your Way' (Run Agents Anywhere, Customize Your Agents) - Drop the inline-suggestions card; not directly agent-related - Apply title-case to step title, group labels, and card titles per UI guidelines - Remove dead align-content from the now-flex sessions container - Re-enable workbench.welcomePage.experimentalOnboarding by default --- .../browser/gettingStarted.contribution.ts | 2 +- .../browser/media/variationA.css | 1 - .../browser/onboardingVariationA.ts | 22 +++++++------------ .../common/onboardingTypes.ts | 2 +- 4 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.ts b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.ts index 5a5f9180bfaff..803f772199871 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.ts @@ -341,7 +341,7 @@ configurationRegistry.registerConfiguration({ 'workbench.welcomePage.experimentalOnboarding': { scope: ConfigurationScope.APPLICATION, type: 'boolean', - default: false, + default: true, tags: ['experimental'], description: localize('workbench.welcomePage.experimentalOnboarding', "When enabled, show the new onboarding experience instead of the classic walkthrough on first launch."), experiment: { diff --git a/src/vs/workbench/contrib/welcomeOnboarding/browser/media/variationA.css b/src/vs/workbench/contrib/welcomeOnboarding/browser/media/variationA.css index 9fed80460c797..1332bc97c6ee3 100644 --- a/src/vs/workbench/contrib/welcomeOnboarding/browser/media/variationA.css +++ b/src/vs/workbench/contrib/welcomeOnboarding/browser/media/variationA.css @@ -727,7 +727,6 @@ flex-direction: column; gap: 14px; flex: 1; - align-content: start; } .onboarding-a-sessions-group { diff --git a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts index a4f2889cb4f6c..d49a371ed8bba 100644 --- a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts +++ b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts @@ -993,10 +993,10 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi const features = append(wrapper, $('.onboarding-a-sessions-features')); - // Group 1: Chat modes — Ask / Agent / Plan + // Group 1: Chat modes — Plan / Agent / Ask const chatGroup = append(features, $('.onboarding-a-sessions-group')); const chatLabel = append(chatGroup, $('div.onboarding-a-sessions-group-label')); - chatLabel.textContent = localize('onboarding.sessions.group.chat', "In the Chat view"); + chatLabel.textContent = localize('onboarding.sessions.group.chat', "Choose Your Agent"); const chatGrid = append(chatGroup, $('.onboarding-a-sessions-grid.onboarding-a-sessions-grid-3')); this._createFeatureCard(chatGrid, Codicon.listOrdered, @@ -1011,25 +1011,19 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi localize('onboarding.sessions.askMode', "Ask"), localize('onboarding.sessions.askMode.desc', "Ask questions about your code or technical concepts and get answers grounded in your codebase \u2014 no file changes.")); - // Group 2: everything else + // Group 2: ways to run and customize agents beyond the default Chat experience const moreGroup = append(features, $('.onboarding-a-sessions-group')); const moreLabel = append(moreGroup, $('div.onboarding-a-sessions-group-label')); - moreLabel.textContent = localize('onboarding.sessions.group.more', "Beyond the Chat view"); + moreLabel.textContent = localize('onboarding.sessions.group.more', "Agents That Work Your Way"); const moreGrid = append(moreGroup, $('.onboarding-a-sessions-grid.onboarding-a-sessions-grid-2')); this._createFeatureCard(moreGrid, Codicon.rocket, - localize('onboarding.sessions.runAnywhere', "Run agents anywhere"), + localize('onboarding.sessions.runAnywhere', "Run Agents Anywhere"), localize('onboarding.sessions.runAnywhere.desc', "Run agents locally for interactive work, in the background with Copilot CLI, or in the cloud with cloud agents that open a pull request your team can review.")); - const inlineDesc = this._createFeatureCard(moreGrid, Codicon.sparkle, - localize('onboarding.sessions.inline', "AI assistance as you type")); - inlineDesc.append( - localize('onboarding.sessions.inline.desc1', "Inline suggestions and next edit predictions appear as you code \u2014 press "), - this._createKbd(localize('onboarding.sessions.inline.tab', "Tab")), - localize('onboarding.sessions.inline.desc2', " to accept. Press "), - this._createKbd(isMacintosh ? '\u2318I' : 'Ctrl+I'), - localize('onboarding.sessions.inline.desc3', " to open inline chat for targeted edits without leaving the editor."), - ); + this._createFeatureCard(moreGrid, Codicon.settingsGear, + localize('onboarding.sessions.customize', "Customize Your Agents"), + localize('onboarding.sessions.customize.desc', "Tailor agents to your project with custom instructions, reusable prompts, chat modes, and MCP servers that connect them to the tools and context you rely on.")); // Tutorial link at bottom of content, above footer const docsRow = append(wrapper, $('.onboarding-a-sessions-docs')); diff --git a/src/vs/workbench/contrib/welcomeOnboarding/common/onboardingTypes.ts b/src/vs/workbench/contrib/welcomeOnboarding/common/onboardingTypes.ts index 1623dae4cee86..0dd5734ae55f2 100644 --- a/src/vs/workbench/contrib/welcomeOnboarding/common/onboardingTypes.ts +++ b/src/vs/workbench/contrib/welcomeOnboarding/common/onboardingTypes.ts @@ -29,7 +29,7 @@ export function getOnboardingStepTitle(stepId: OnboardingStepId): string { case OnboardingStepId.AiPreference: return localize('onboarding.step.aiPreference', "Your AI Style"); case OnboardingStepId.AgentSessions: - return localize('onboarding.step.agentSessions', "Build with AI agents"); + return localize('onboarding.step.agentSessions', "Build with AI Agents"); } } From 0b47e3496de44b3340b96b89d59050be4ce7b273 Mon Sep 17 00:00:00 2001 From: Elijah King Date: Wed, 22 Apr 2026 16:39:17 -0700 Subject: [PATCH 09/32] Update src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts Co-authored-by: Courtney Webster <60238438+cwebster-99@users.noreply.github.com> --- .../contrib/welcomeOnboarding/browser/onboardingVariationA.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts index d49a371ed8bba..edae61a3262e0 100644 --- a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts +++ b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts @@ -1005,7 +1005,7 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi this._createFeatureCard(chatGrid, Codicon.commentDiscussion, localize('onboarding.sessions.agentMode', "Agent"), - localize('onboarding.sessions.agentMode.desc', "Describe a goal. The agent plans the approach, edits files, runs commands, and self-corrects \u2014 you review and approve along the way.")); + localize('onboarding.sessions.agentMode.desc', "Describe a goal. The agent plans the approach, edits files, runs commands, and self-corrects. You review and approve along the way.")); this._createFeatureCard(chatGrid, Codicon.comment, localize('onboarding.sessions.askMode', "Ask"), From 4dca85967288f4140d5e093db771f464fd6c0338 Mon Sep 17 00:00:00 2001 From: Elijah King Date: Wed, 22 Apr 2026 16:39:29 -0700 Subject: [PATCH 10/32] Update src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts Co-authored-by: Courtney Webster <60238438+cwebster-99@users.noreply.github.com> --- .../contrib/welcomeOnboarding/browser/onboardingVariationA.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts index edae61a3262e0..7cc873dc77a11 100644 --- a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts +++ b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts @@ -1009,7 +1009,7 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi this._createFeatureCard(chatGrid, Codicon.comment, localize('onboarding.sessions.askMode', "Ask"), - localize('onboarding.sessions.askMode.desc', "Ask questions about your code or technical concepts and get answers grounded in your codebase \u2014 no file changes.")); + localize('onboarding.sessions.askMode.desc', "Ask questions about your code or technical concepts and get answers grounded in your codebase.")); // Group 2: ways to run and customize agents beyond the default Chat experience const moreGroup = append(features, $('.onboarding-a-sessions-group')); From 12ea8534d11dacfed0dce0b966b70d03204d227d Mon Sep 17 00:00:00 2001 From: Elijah King Date: Wed, 22 Apr 2026 16:39:41 -0700 Subject: [PATCH 11/32] Update src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts Co-authored-by: Courtney Webster <60238438+cwebster-99@users.noreply.github.com> --- .../contrib/welcomeOnboarding/browser/onboardingVariationA.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts index 7cc873dc77a11..bbffe470108b7 100644 --- a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts +++ b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts @@ -1023,7 +1023,7 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi this._createFeatureCard(moreGrid, Codicon.settingsGear, localize('onboarding.sessions.customize', "Customize Your Agents"), - localize('onboarding.sessions.customize.desc', "Tailor agents to your project with custom instructions, reusable prompts, chat modes, and MCP servers that connect them to the tools and context you rely on.")); + localize('onboarding.sessions.customize.desc', "Tailor Copilot to your project with custom instructions and agents, skills, reusable prompts, and MCP servers that connect to the tools and context you rely on.")); // Tutorial link at bottom of content, above footer const docsRow = append(wrapper, $('.onboarding-a-sessions-docs')); From 9b56cf6a86700f47b894e91740d71f4b7b722365 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Thu, 23 Apr 2026 10:59:29 +0200 Subject: [PATCH 12/32] Dispose store on error Co-authored-by: Copilot --- src/vs/base/test/common/timeTravelScheduler.ts | 2 +- .../test/browser/componentFixtures/fixtureUtils.ts | 10 +++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/vs/base/test/common/timeTravelScheduler.ts b/src/vs/base/test/common/timeTravelScheduler.ts index 673669732f60f..c495921002517 100644 --- a/src/vs/base/test/common/timeTravelScheduler.ts +++ b/src/vs/base/test/common/timeTravelScheduler.ts @@ -201,7 +201,7 @@ export class AsyncSchedulerProcessor extends Disposable { } } - waitFor(virtualTimeMs: number): Promise { + runForVirtualTimeMs(virtualTimeMs: number): Promise { this._virtualDeadline = this.scheduler.now + virtualTimeMs; return this.waitForEmptyQueue().finally(() => { this._virtualDeadline = Number.MAX_SAFE_INTEGER; diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts index cabbc1cdcf710..6825f04a2849d 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -671,8 +671,8 @@ export function defineComponentFixture(options: ComponentFixtureOptions): Themed isolation: 'none', displayMode: { type: 'component' }, background: theme === darkTheme ? 'dark' : 'light', - render: async (container: HTMLElement) => { - const disposableStore = new DisposableStore(); + render: async (container: HTMLElement, context) => { + const disposableStore = context.addDisposable(new DisposableStore()); const schedulerStore = disposableStore.add(new DisposableStore()); const scheduler = new TimeTravelScheduler(Date.now()); @@ -706,7 +706,7 @@ export function defineComponentFixture(options: ComponentFixtureOptions): Themed const result = options.render({ container, disposableStore, theme }); - const p2 = p.waitFor(1000); + const p2 = p.runForVirtualTimeMs(1000); await Promise.all([ result instanceof Promise ? result : Promise.resolve(), @@ -715,10 +715,6 @@ export function defineComponentFixture(options: ComponentFixtureOptions): Themed } await actualRender(); - - schedulerStore.dispose(); - - return disposableStore; }, }); From 997722d3eb90c3f316bfd6dddbe4c846ae458c06 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Thu, 23 Apr 2026 11:30:48 +0200 Subject: [PATCH 13/32] Fixes CI --- src/vs/base/test/common/timeTravelScheduler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/base/test/common/timeTravelScheduler.ts b/src/vs/base/test/common/timeTravelScheduler.ts index c495921002517..4ea4b89461c19 100644 --- a/src/vs/base/test/common/timeTravelScheduler.ts +++ b/src/vs/base/test/common/timeTravelScheduler.ts @@ -260,7 +260,7 @@ export const originalGlobalValues: TimeApi = captureGlobalTimeApi(); // Expose the real setTimeout for the component explorer runtime, which needs true time // even when virtual time is installed for fixtures. // eslint-disable-next-line local/code-no-any-casts -(window as any).setTimeout_original = originalGlobalValues.setTimeout; +(originalGlobalValues.setTimeout as any).originalFn = originalGlobalValues.setTimeout; export interface CreateVirtualTimeApiOptions { fakeRequestAnimationFrame?: boolean; From ef34fe62d2c2013968709e6d42479b1676922120 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Thu, 23 Apr 2026 12:00:34 +0200 Subject: [PATCH 14/32] Handles failing tasks Co-authored-by: Copilot --- src/vs/base/test/common/timeTravelScheduler.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/vs/base/test/common/timeTravelScheduler.ts b/src/vs/base/test/common/timeTravelScheduler.ts index 4ea4b89461c19..f927a82403fe2 100644 --- a/src/vs/base/test/common/timeTravelScheduler.ts +++ b/src/vs/base/test/common/timeTravelScheduler.ts @@ -155,7 +155,12 @@ export class AsyncSchedulerProcessor extends Disposable { } private _process() { - const executedTask = this.scheduler.runNext(); + let executedTask: ScheduledTask | undefined; + try { + executedTask = this.scheduler.runNext(); + } catch (e) { + console.error(`[TimeTravelScheduler] Task threw:`, e); + } if (executedTask) { this._history.push(executedTask); From 50ae9e666b2130adcde2fb3b8fca3576120a7ee3 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:54:32 +0200 Subject: [PATCH 15/32] Agents app: keybindings to switch between sessions (Ctrl+1..9) (#310994) * Initial plan * Add Ctrl/Cmd+1..9 keybindings to switch sessions in the Agents app Agent-Logs-Url: https://github.com/microsoft/vscode/sessions/d75b30ac-58ce-49b7-b32f-c6b4e7794f31 Co-authored-by: ulugbekna <16353531+ulugbekna@users.noreply.github.com> * Use Ctrl on macOS too for session-switching shortcuts Agent-Logs-Url: https://github.com/microsoft/vscode/sessions/2772740d-836f-4dc1-be18-54d0968c9c3d Co-authored-by: ulugbekna <16353531+ulugbekna@users.noreply.github.com> * Bump weight to override editor's openEditorAtIndex on macOS Agent-Logs-Url: https://github.com/microsoft/vscode/sessions/c80fda2c-9e56-4a5b-83e6-13a387874ed8 Co-authored-by: ulugbekna <16353531+ulugbekna@users.noreply.github.com> * Derive visible sessions from tree model Address PR review: getVisibleSessions() now traverses the tree model after setChildren() so the index-based Ctrl+1..9 navigation matches what is actually visible to the user. Respects collapsed sections, find-widget filtering, and skips section/show-more nodes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ulugbekna <16353531+ulugbekna@users.noreply.github.com> Co-authored-by: ulugbekna Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessions/browser/views/sessionsList.ts | 35 +++++++++++ .../browser/views/sessionsViewActions.ts | 61 +++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index e32f285a8674b..a30cab968356b 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -637,6 +637,11 @@ export interface ISessionsList { readonly onDidChangeFindOpenState: Event; refresh(): void; reveal(sessionResource: URI): boolean; + /** + * Returns the sessions currently visible in the list, in display order. + * Sessions hidden by workspace group capping ("show more") are excluded. + */ + getVisibleSessions(): readonly ISession[]; clearFocus(): void; hasFocusOrSelection(): boolean; setVisible(visible: boolean): void; @@ -954,6 +959,36 @@ export class SessionsList extends Disposable implements ISessionsList { this._onDidUpdate.fire(); } + getVisibleSessions(): readonly ISession[] { + // Derive the visible session list from the tree model so that index-based + // navigation matches what the user actually sees: this respects collapsed + // sections, find-widget filtering, and excludes section / show-more nodes. + const sessions = new Set(this.sessions); + const visibleSessions: ISession[] = []; + + const collect = (node: ITreeNode): void => { + if (!node.visible) { + return; + } + if (node.element && sessions.has(node.element as ISession)) { + visibleSessions.push(node.element as ISession); + } + if (node.collapsed) { + return; + } + for (const child of node.children) { + collect(child); + } + }; + + const root = this.tree.getNode(); + for (const child of root.children) { + collect(child); + } + + return visibleSessions; + } + reveal(sessionResource: URI): boolean { const resourceStr = sessionResource.toString(); for (const session of this.sessions) { diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index d15c9f6b36156..1df5e24d098c7 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -7,6 +7,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { localize, localize2 } from '../../../../../nls.js'; import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -64,6 +65,66 @@ KeybindingsRegistry.registerKeybindingRule({ win: { primary: KeyMod.CtrlCmd | KeyCode.F4, secondary: [KeyMod.CtrlCmd | KeyCode.KeyW] }, }); +// Open Session at Index (Ctrl/Cmd+1..9) + +const OPEN_SESSION_AT_INDEX_COMMAND_ID = 'sessionsViewPane.openSessionAtIndex'; + +function digitToKeyCode(digit: number): KeyCode { + switch (digit) { + case 1: return KeyCode.Digit1; + case 2: return KeyCode.Digit2; + case 3: return KeyCode.Digit3; + case 4: return KeyCode.Digit4; + case 5: return KeyCode.Digit5; + case 6: return KeyCode.Digit6; + case 7: return KeyCode.Digit7; + case 8: return KeyCode.Digit8; + case 9: return KeyCode.Digit9; + default: return KeyCode.Unknown; + } +} + +const openSessionAtIndex = (accessor: ServicesAccessor, sessionIndex: unknown): void => { + if (typeof sessionIndex !== 'number') { + return; + } + const viewsService = accessor.get(IViewsService); + const sessionsManagementService = accessor.get(ISessionsManagementService); + const view = viewsService.getViewWithId(SessionsViewId); + const visible = view?.sessionsControl?.getVisibleSessions() ?? []; + if (visible.length === 0) { + return; + } + // Index -1 means "last session" + const target = sessionIndex === -1 + ? visible[visible.length - 1] + : visible[sessionIndex]; + if (!target) { + return; + } + sessionsManagementService.openSession(target.resource); +}; + +CommandsRegistry.registerCommand({ + id: OPEN_SESSION_AT_INDEX_COMMAND_ID, + handler: openSessionAtIndex +}); + +// Ctrl/Cmd+1..8 open the Nth session, Ctrl/Cmd+9 opens the last session +for (let visibleIndex = 1; visibleIndex <= 9; visibleIndex++) { + const sessionIndex = visibleIndex === 9 ? -1 : visibleIndex - 1; + KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: OPEN_SESSION_AT_INDEX_COMMAND_ID + visibleIndex, + // Higher than WorkbenchContrib to override `workbench.action.openEditorAtIndexN` (Ctrl+N on macOS) + weight: KeybindingWeight.WorkbenchContrib + 1, + when: IsSessionsWindowContext, + // Always use Ctrl (not Cmd on macOS) to avoid conflicting with Cmd+N view focus shortcuts + primary: KeyMod.CtrlCmd | digitToKeyCode(visibleIndex), + mac: { primary: KeyMod.WinCtrl | digitToKeyCode(visibleIndex) }, + handler: accessor => openSessionAtIndex(accessor, sessionIndex) + }); +} + // View Title Menu MenuRegistry.appendMenuItem(Menus.SidebarSessionsHeader, { From c809950e88edde8a9f3d0a14231a96bfd77cdc6b Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 23 Apr 2026 13:05:53 +0200 Subject: [PATCH 16/32] Remember last active chat per session across restarts (#312108) * Remember last active chat per session across restarts Persist per-session state (ISessionState) in the sessions management service so that when a session is reopened, the last active chat is restored instead of always defaulting to the first chat. - Add ISessionState interface with sessionResource, activeChatResource, and isActive fields, stored as a JSON array and loaded into a ResourceMap keyed by session resource URI - Restore last active chat in setActiveSession() when switching sessions - Track active chat changes via autorun to keep persisted state current - Mark the currently active session with isActive flag for reload - Remove legacy lastSelectedSession/LAST_SELECTED_SESSION_KEY storage (consolidated into the new session state) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update src/vs/sessions/services/sessions/browser/sessionsManagementService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../browser/sessionsManagementService.ts | 99 ++++++++++++++----- 1 file changed, 77 insertions(+), 22 deletions(-) diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 3b4c281795bd4..f362a407405d1 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -5,6 +5,7 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../base/common/map.js'; import { IObservable, ISettableObservable, autorun, observableValue } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -20,9 +21,23 @@ import { ISendRequestOptions, ISessionChangeEvent, ISessionsProvider } from '../ import { COPILOT_CLI_SESSION_TYPE, IChat, ISession, SessionStatus, ISessionType } from '../common/session.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -const LAST_SELECTED_SESSION_KEY = 'agentSessions.lastSelectedSession'; +const ACTIVE_SESSION_STATES_KEY = 'agentSessions.activeSessionStates'; const ACTIVE_PROVIDER_KEY = 'sessions.activeProviderId'; +/** + * 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; +} + class SessionsManagementService extends Disposable implements ISessionsManagementService { declare readonly _serviceBrand: undefined; @@ -39,7 +54,6 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen readonly activeSession: IObservable = this._activeSession; private readonly _activeProviderId = observableValue(this, undefined); readonly activeProviderId: IObservable = this._activeProviderId; - private lastSelectedSession: URI | undefined; private readonly isNewChatSessionContext: IContextKey; private readonly _isNewChatInSessionContext: IContextKey; private readonly _activeSessionProviderId: IContextKey; @@ -50,6 +64,7 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen private _activeChatObservable: ISettableObservable | undefined; private _activeSessionDisposables = this._register(new DisposableStore()); private readonly _providerListeners = this._register(new DisposableMap()); + private readonly _sessionStates: ResourceMap; constructor( @IStorageService private readonly storageService: IStorageService, @@ -72,11 +87,11 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen this._isActiveSessionArchived = IsActiveSessionArchivedContext.bindTo(contextKeyService); this._supportsMultiChat = ActiveSessionSupportsMultiChatContext.bindTo(contextKeyService); - // Load last selected session - this.lastSelectedSession = this.loadLastSelectedSession(); + // Load persisted state + this._sessionStates = this._loadSessionStates(); // Save on shutdown - this._register(this.storageService.onWillSaveState(() => this.saveLastSelectedSession())); + this._register(this.storageService.onWillSaveState(() => this._saveSessionStates())); // Restore or auto-select active provider this._initActiveProvider(); @@ -246,7 +261,10 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen this._isNewChatInSessionContext.set(false); this.setActiveSession(sessionData); - await this.chatWidgetService.openSession(sessionData.resource, ChatViewPaneTarget, { preserveFocus: options?.preserveFocus }); + // Open the active chat (which may have been restored to the last active chat) + const activeChat = this._activeSession.get()?.activeChat.get(); + const openUri = activeChat?.resource ?? sessionData.resource; + await this.chatWidgetService.openSession(openUri, ChatViewPaneTarget, { preserveFocus: options?.preserveFocus }); } unsetNewSession(): void { @@ -376,10 +394,6 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen this._isActiveSessionArchived.set(session?.isArchived.get() ?? false); this._supportsMultiChat.set(session?.capabilities.supportsMultipleChats ?? false); - if (session && session.status.get() !== SessionStatus.Untitled) { - this.lastSelectedSession = session.resource; - } - if (session) { this.logService.info(`[ActiveSessionService] Active session changed: ${session.resource.toString()}`); @@ -396,8 +410,24 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen this._activeSessionDisposables.clear(); if (session) { - // Create the active chat observable, defaulting to the first chat - const activeChatObs = observableValue(`activeChat-${session.sessionId}`, session.chats.get()[0]); + // Restore the last active chat for this session, or default to the first chat + const chats = session.chats.get(); + const sessionState = this._sessionStates.get(session.resource); + let initialChat = chats[0]; + 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); + } + } + + // Create the active chat observable + const activeChatObs = observableValue(`activeChat-${session.sessionId}`, initialChat); this._activeChatObservable = activeChatObs; const activeSession: IActiveSession = { ...session, @@ -428,29 +458,54 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen } } })); + + // Track active chat changes to persist per-session state + this._activeSessionDisposables.add(autorun(reader => { + const chat = activeChatObs.read(reader); + if (chat && chat.status.read(undefined) !== SessionStatus.Untitled) { + // Mark all sessions as inactive, then set this one as active + for (const [, state] of this._sessionStates) { + state.isActive = false; + } + const existing = this._sessionStates.get(session.resource); + this._sessionStates.set(session.resource, { + ...existing, + sessionResource: session.resource.toString(), + activeChatResource: chat.resource.toString(), + isActive: true, + }); + } + })); } else { this._activeChatObservable = undefined; this._activeSession.set(undefined, undefined); } } - private loadLastSelectedSession(): URI | undefined { - const cached = this.storageService.get(LAST_SELECTED_SESSION_KEY, StorageScope.WORKSPACE); - if (!cached) { - return undefined; + private _loadSessionStates(): ResourceMap { + const map = new ResourceMap(); + const raw = this.storageService.get(ACTIVE_SESSION_STATES_KEY, StorageScope.WORKSPACE); + if (!raw) { + return map; } - try { - return URI.parse(cached); + const entries: ISessionState[] = JSON.parse(raw); + for (const entry of entries) { + const uri = URI.parse(entry.sessionResource); + map.set(uri, entry); + } } catch { - return undefined; + // ignore corrupt data } + return map; } - private saveLastSelectedSession(): void { - if (this.lastSelectedSession) { - this.storageService.store(LAST_SELECTED_SESSION_KEY, this.lastSelectedSession.toString(), StorageScope.WORKSPACE, StorageTarget.MACHINE); + private _saveSessionStates(): void { + const entries: ISessionState[] = []; + for (const [, state] of this._sessionStates) { + entries.push(state); } + this.storageService.store(ACTIVE_SESSION_STATES_KEY, JSON.stringify(entries), StorageScope.WORKSPACE, StorageTarget.MACHINE); } // -- Session Actions -- From 053e4f433e72f93b49964f72a123a191fa004ffc Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 23 Apr 2026 13:16:50 +0200 Subject: [PATCH 17/32] fix view menu (#312116) - remove explorer - bring in output - add keyboard shortcut for sessions Co-authored-by: Copilot --- .../changes/browser/changes.contribution.ts | 6 +- .../contrib/chat/browser/chat.contribution.ts | 10 +-- .../browser/chatDebug.contribution.ts | 6 +- .../files/browser/files.contribution.ts | 8 +-- .../contrib/logs/browser/logs.contribution.ts | 67 ------------------- .../sessions/browser/sessions.contribution.ts | 8 ++- src/vs/sessions/sessions.common.main.ts | 1 - src/vs/workbench/common/views.ts | 12 ++-- .../output/browser/output.contribution.ts | 6 +- .../terminal/browser/terminal.contribution.ts | 6 +- .../views/browser/viewDescriptorService.ts | 53 ++++++++------- 11 files changed, 60 insertions(+), 123 deletions(-) delete mode 100644 src/vs/sessions/contrib/logs/browser/logs.contribution.ts diff --git a/src/vs/sessions/contrib/changes/browser/changes.contribution.ts b/src/vs/sessions/contrib/changes/browser/changes.contribution.ts index f55765a4c93f8..dfbd37604e9d0 100644 --- a/src/vs/sessions/contrib/changes/browser/changes.contribution.ts +++ b/src/vs/sessions/contrib/changes/browser/changes.contribution.ts @@ -9,7 +9,7 @@ import { SyncDescriptor } from '../../../../platform/instantiation/common/descri import { Registry } from '../../../../platform/registry/common/platform.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { IViewContainersRegistry, ViewContainerLocation, IViewsRegistry, Extensions as ViewContainerExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; +import { IViewContainersRegistry, ViewContainerLocation, IViewsRegistry, Extensions as ViewContainerExtensions, WindowEnablement } from '../../../../workbench/common/views.js'; import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID } from '../common/changes.js'; import { ChangesViewPane, ChangesViewPaneContainer } from './changesView.js'; import { ChangesTitleBarContribution } from './changesTitleBarWidget.js'; @@ -40,7 +40,7 @@ const changesViewContainer = viewContainersRegistry.registerViewContainer({ }, order: 1, }, - windowVisibility: WindowVisibility.Sessions + windowEnablement: WindowEnablement.Sessions }, ViewContainerLocation.AuxiliaryBar); const viewsRegistry = Registry.as(ViewContainerExtensions.ViewsRegistry); @@ -54,7 +54,7 @@ viewsRegistry.registerViews([{ canMoveView: false, weight: 100, order: 1, - windowVisibility: WindowVisibility.Sessions, + windowEnablement: WindowEnablement.Sessions, }], changesViewContainer); registerWorkbenchContribution2(ChangesTitleBarContribution.ID, ChangesTitleBarContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index e3eb8f2a2535a..c00f663b3c61d 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -9,7 +9,7 @@ import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js import { localize, localize2 } from '../../../../nls.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { IViewContainersRegistry, IViewsRegistry, ViewContainerLocation, Extensions as ViewExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; +import { IViewContainersRegistry, IViewsRegistry, ViewContainerLocation, Extensions as ViewExtensions, WindowEnablement } from '../../../../workbench/common/views.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; @@ -100,7 +100,7 @@ class RegisterChatViewContainerContribution implements IWorkbenchContribution { storageId: ChatViewContainerId, hideIfEmpty: true, order: 1, - windowVisibility: WindowVisibility.Sessions, + windowEnablement: WindowEnablement.Sessions, }, ViewContainerLocation.ChatBar, { isDefault: true, doNotRegisterOpenCommand: true }); viewsRegistry.registerViews([{ @@ -113,7 +113,7 @@ class RegisterChatViewContainerContribution implements IWorkbenchContribution { canMoveView: false, ctorDescriptor: new SyncDescriptor(ChatViewPane), when: ContextKeyExpr.and(IsNewChatSessionContext.negate(), IsNewChatInSessionContext.negate()), - windowVisibility: WindowVisibility.Sessions + windowEnablement: WindowEnablement.Sessions }, { id: SessionsViewId, containerIcon: chatViewContainer.icon, @@ -124,7 +124,7 @@ class RegisterChatViewContainerContribution implements IWorkbenchContribution { canMoveView: false, ctorDescriptor: new SyncDescriptor(NewChatViewPane), when: IsNewChatSessionContext, - windowVisibility: WindowVisibility.Sessions, + windowEnablement: WindowEnablement.Sessions, }, { id: NewChatInSessionViewId, containerIcon: chatViewContainer.icon, @@ -135,7 +135,7 @@ class RegisterChatViewContainerContribution implements IWorkbenchContribution { canMoveView: false, ctorDescriptor: new SyncDescriptor(NewChatInSessionViewPane), when: ContextKeyExpr.and(IsNewChatSessionContext.negate(), IsNewChatInSessionContext), - windowVisibility: WindowVisibility.Sessions, + windowEnablement: WindowEnablement.Sessions, }], chatViewContainer); } } diff --git a/src/vs/sessions/contrib/chatDebug/browser/chatDebug.contribution.ts b/src/vs/sessions/contrib/chatDebug/browser/chatDebug.contribution.ts index dbb92718c71b6..6a707ebe1282c 100644 --- a/src/vs/sessions/contrib/chatDebug/browser/chatDebug.contribution.ts +++ b/src/vs/sessions/contrib/chatDebug/browser/chatDebug.contribution.ts @@ -11,7 +11,7 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { IViewContainersRegistry, IViewDescriptor, IViewsRegistry, ViewContainerLocation, Extensions as ViewContainerExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; +import { IViewContainersRegistry, IViewDescriptor, IViewsRegistry, ViewContainerLocation, Extensions as ViewContainerExtensions, WindowEnablement } from '../../../../workbench/common/views.js'; const COPILOT_CHAT_VIEW_CONTAINER_ID = 'workbench.view.extension.copilot-chat'; const COPILOT_CHAT_VIEW_ID = 'copilot-chat'; @@ -70,14 +70,14 @@ class RegisterChatDebugViewContribution extends Disposable implements IWorkbench ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [SESSIONS_CHAT_DEBUG_CONTAINER_ID, { mergeViewWithContainerWhenSingleView: true }]), storageId: SESSIONS_CHAT_DEBUG_CONTAINER_ID, hideIfEmpty: true, - windowVisibility: WindowVisibility.Sessions, + windowEnablement: WindowEnablement.Sessions, }, ViewContainerLocation.Panel, { doNotRegisterOpenCommand: true }); // Re-register the view inside the new sessions container const sessionsView: IViewDescriptor = { ...view, canMoveView: false, - windowVisibility: WindowVisibility.Sessions, + windowEnablement: WindowEnablement.Sessions, }; viewsRegistry.registerViews([sessionsView], chatDebugViewContainer); diff --git a/src/vs/sessions/contrib/files/browser/files.contribution.ts b/src/vs/sessions/contrib/files/browser/files.contribution.ts index f472c507c069f..28b17329f7b9c 100644 --- a/src/vs/sessions/contrib/files/browser/files.contribution.ts +++ b/src/vs/sessions/contrib/files/browser/files.contribution.ts @@ -12,7 +12,7 @@ import { ServicesAccessor } from '../../../../platform/instantiation/common/inst import { Registry } from '../../../../platform/registry/common/platform.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { IViewContainersRegistry, IViewsRegistry, ViewContainerLocation, Extensions as ViewContainerExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; +import { IViewContainersRegistry, IViewsRegistry, ViewContainerLocation, Extensions as ViewContainerExtensions, WindowEnablement } from '../../../../workbench/common/views.js'; import { ExplorerView } from '../../../../workbench/contrib/files/browser/views/explorerView.js'; import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; @@ -42,7 +42,7 @@ const filesViewContainer = viewContainerRegistry.registerViewContainer({ keybindings: { primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyE }, order: 0 }, - windowVisibility: WindowVisibility.Sessions, + windowEnablement: WindowEnablement.Sessions, }, ViewContainerLocation.AuxiliaryBar, { isDefault: true }); class RegisterFilesViewContribution implements IWorkbenchContribution { @@ -61,7 +61,7 @@ class RegisterFilesViewContribution implements IWorkbenchContribution { canToggleVisibility: false, canMoveView: false, when: WorkspaceFolderCountContext.notEqualsTo('0'), - windowVisibility: WindowVisibility.Sessions, + windowEnablement: WindowEnablement.Sessions, }], filesViewContainer); // Register an empty view to show when there are no workspace folders @@ -73,7 +73,7 @@ class RegisterFilesViewContribution implements IWorkbenchContribution { canToggleVisibility: false, canMoveView: false, when: WorkspaceFolderCountContext.isEqualTo('0'), - windowVisibility: WindowVisibility.Sessions, + windowEnablement: WindowEnablement.Sessions, }], filesViewContainer); } } diff --git a/src/vs/sessions/contrib/logs/browser/logs.contribution.ts b/src/vs/sessions/contrib/logs/browser/logs.contribution.ts deleted file mode 100644 index 8a562ce4977c8..0000000000000 --- a/src/vs/sessions/contrib/logs/browser/logs.contribution.ts +++ /dev/null @@ -1,67 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Codicon } from '../../../../base/common/codicons.js'; -import { localize, localize2 } from '../../../../nls.js'; -import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; -import { Registry } from '../../../../platform/registry/common/platform.js'; -import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; -import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; -import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { IViewContainersRegistry, IViewsRegistry, ViewContainerLocation, Extensions as ViewContainerExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; -import { OutputViewPane } from '../../../../workbench/contrib/output/browser/outputView.js'; -import { OUTPUT_VIEW_ID } from '../../../../workbench/services/output/common/output.js'; - -const SESSIONS_LOGS_CONTAINER_ID = 'workbench.sessions.panel.logsContainer'; - -const logsViewIcon = registerIcon('sessions-logs-view-icon', Codicon.output, localize('sessionsLogsViewIcon', 'View icon of the logs view in the sessions window.')); - -class RegisterLogsViewContainerContribution implements IWorkbenchContribution { - - static readonly ID = 'sessions.registerLogsViewContainer'; - - constructor( - @IContextKeyService contextKeyService: IContextKeyService, - ) { - const viewContainerRegistry = Registry.as(ViewContainerExtensions.ViewContainersRegistry); - const viewsRegistry = Registry.as(ViewContainerExtensions.ViewsRegistry); - - // Deregister the output view and its container from the original registration - const outputViewContainer = viewContainerRegistry.get(OUTPUT_VIEW_ID); - if (outputViewContainer) { - const view = viewsRegistry.getView(OUTPUT_VIEW_ID); - if (view) { - viewsRegistry.deregisterViews([view], outputViewContainer); - } - viewContainerRegistry.deregisterViewContainer(outputViewContainer); - } - - // Register a new logs view container in the Panel for the sessions window - const logsViewContainer = viewContainerRegistry.registerViewContainer({ - id: SESSIONS_LOGS_CONTAINER_ID, - title: localize2('logs', "Logs"), - icon: logsViewIcon, - order: 2, - ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [SESSIONS_LOGS_CONTAINER_ID, { mergeViewWithContainerWhenSingleView: true }]), - storageId: SESSIONS_LOGS_CONTAINER_ID, - hideIfEmpty: true, - windowVisibility: WindowVisibility.Sessions, - }, ViewContainerLocation.Panel, { doNotRegisterOpenCommand: true }); - - // Re-register the output view inside the new logs container with a `when` context - viewsRegistry.registerViews([{ - id: OUTPUT_VIEW_ID, - name: localize2('logs', "Logs"), - containerIcon: logsViewIcon, - ctorDescriptor: new SyncDescriptor(OutputViewPane), - canToggleVisibility: true, - canMoveView: false, - windowVisibility: WindowVisibility.Sessions, - }], logsViewContainer); - } -} - -registerWorkbenchContribution2(RegisterLogsViewContainerContribution.ID, RegisterLogsViewContainerContribution, WorkbenchPhase.BlockStartup); diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index 84362122209ff..517ca49e99508 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -5,7 +5,7 @@ import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; -import { IViewDescriptor, IViewsRegistry, Extensions as ViewContainerExtensions, WindowVisibility, ViewContainer, IViewContainersRegistry, ViewContainerLocation } from '../../../../workbench/common/views.js'; +import { IViewDescriptor, IViewsRegistry, Extensions as ViewContainerExtensions, WindowEnablement, ViewContainer, IViewContainersRegistry, ViewContainerLocation } from '../../../../workbench/common/views.js'; import { localize, localize2 } from '../../../../nls.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; @@ -15,6 +15,7 @@ import { SessionsTitleBarContribution } from './sessionsTitleBarWidget.js'; import { SessionsView, SessionsViewId } from './views/sessionsView.js'; import './views/sessionsViewActions.js'; import './sessionsActions.js'; +import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; const agentSessionsViewIcon = registerIcon('chat-sessions-icon', Codicon.commentDiscussionSparkle, localize('agentSessionsViewIcon', 'Icon for Agent Sessions View')); const AGENT_SESSIONS_VIEW_TITLE = localize2('agentSessions.view.label', "Sessions"); @@ -31,9 +32,10 @@ const agentSessionsViewContainer: ViewContainer = Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews([sessionsViewPaneDescriptor], agentSessionsViewContainer); diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index 2a646ea78712d..5b40930d79efe 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -453,7 +453,6 @@ import './contrib/workingSet/browser/workingSet.contribution.js'; import './contrib/editor/browser/editor.contribution.js'; import './contrib/terminal/browser/sessionsTerminalContribution.js'; -import './contrib/logs/browser/logs.contribution.js'; import './contrib/chatDebug/browser/chatDebug.contribution.js'; import './contrib/workspace/browser/workspace.contribution.js'; import './contrib/welcome/browser/welcome.contribution.js'; diff --git a/src/vs/workbench/common/views.ts b/src/vs/workbench/common/views.ts index cdd589165393d..29b8e0445288a 100644 --- a/src/vs/workbench/common/views.ts +++ b/src/vs/workbench/common/views.ts @@ -63,7 +63,7 @@ type OpenCommandActionDescriptor = { /** * Specifies in which window a view or view container should be visible. */ -export const enum WindowVisibility { +export const enum WindowEnablement { /** * Visible only in the editor window */ @@ -140,10 +140,10 @@ export interface IViewContainerDescriptor { readonly rejectAddedViews?: boolean; /** - * Specifies in which window this view container should be visible. - * Defaults to WindowVisibility.Editor + * Specifies in which window this view container should be enabled. + * Defaults to WindowEnablement.Editor */ - readonly windowVisibility?: WindowVisibility; + readonly windowEnablement?: WindowEnablement; requestedIndex?: number; } @@ -328,9 +328,9 @@ export interface IViewDescriptor { /** * Specifies in which window this view should be visible. - * Defaults to WindowVisibility.Workbench (main workbench only). + * Defaults to WindowEnablement.Workbench (main workbench only). */ - readonly windowVisibility?: WindowVisibility; + readonly windowEnablement?: WindowEnablement; } export interface ICustomViewDescriptor extends IViewDescriptor { diff --git a/src/vs/workbench/contrib/output/browser/output.contribution.ts b/src/vs/workbench/contrib/output/browser/output.contribution.ts index 0ce1bdfa22d0a..023eae28ecad5 100644 --- a/src/vs/workbench/contrib/output/browser/output.contribution.ts +++ b/src/vs/workbench/contrib/output/browser/output.contribution.ts @@ -16,7 +16,7 @@ import { SyncDescriptor } from '../../../../platform/instantiation/common/descri import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions, IWorkbenchContribution } from '../../../common/contributions.js'; import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { ViewContainer, IViewContainersRegistry, ViewContainerLocation, Extensions as ViewContainerExtensions, IViewsRegistry } from '../../../common/views.js'; +import { ViewContainer, IViewContainersRegistry, ViewContainerLocation, Extensions as ViewContainerExtensions, IViewsRegistry, WindowEnablement } from '../../../common/views.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; import { ViewPaneContainer } from '../../../browser/parts/views/viewPaneContainer.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from '../../../../platform/configuration/common/configurationRegistry.js'; @@ -78,6 +78,7 @@ const VIEW_CONTAINER: ViewContainer = Registry.as(ViewC ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [OUTPUT_VIEW_ID, { mergeViewWithContainerWhenSingleView: true }]), storageId: OUTPUT_VIEW_ID, hideIfEmpty: true, + windowEnablement: WindowEnablement.Both }, ViewContainerLocation.Panel, { doNotRegisterOpenCommand: true }); Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews([{ @@ -97,7 +98,8 @@ Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews } }, order: 1, - } + }, + windowEnablement: WindowEnablement.Both }], VIEW_CONTAINER); class OutputContribution extends Disposable implements IWorkbenchContribution { diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts b/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts index be3baf06aebbe..97485af0ea9bd 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts @@ -19,7 +19,7 @@ import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/edit import { ViewPaneContainer } from '../../../browser/parts/views/viewPaneContainer.js'; import { WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; import { EditorExtensions, IEditorFactoryRegistry } from '../../../common/editor.js'; -import { IViewContainersRegistry, IViewsRegistry, Extensions as ViewContainerExtensions, ViewContainerLocation, WindowVisibility } from '../../../common/views.js'; +import { IViewContainersRegistry, IViewsRegistry, Extensions as ViewContainerExtensions, ViewContainerLocation, WindowEnablement } from '../../../common/views.js'; import { ITerminalProfileService, TERMINAL_VIEW_ID, TerminalCommandId } from '../common/terminal.js'; import { TerminalEditingService } from './terminalEditingService.js'; import { registerColors } from '../common/terminalColorRegistry.js'; @@ -113,7 +113,7 @@ const VIEW_CONTAINER = Registry.as(ViewContainerExtensi storageId: TERMINAL_VIEW_ID, hideIfEmpty: true, order: 3, - windowVisibility: WindowVisibility.Both + windowEnablement: WindowEnablement.Both }, ViewContainerLocation.Panel, { doNotRegisterOpenCommand: true, isDefault: true }); Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews([{ id: TERMINAL_VIEW_ID, @@ -122,7 +122,7 @@ Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews canToggleVisibility: true, canMoveView: true, ctorDescriptor: new SyncDescriptor(TerminalViewPane), - windowVisibility: WindowVisibility.Both, + windowEnablement: WindowEnablement.Both, openCommandActionDescriptor: { id: TerminalCommandId.Toggle, mnemonicTitle: nls.localize({ key: 'miToggleIntegratedTerminal', comment: ['&& denotes a mnemonic'] }, "&&Terminal"), diff --git a/src/vs/workbench/services/views/browser/viewDescriptorService.ts b/src/vs/workbench/services/views/browser/viewDescriptorService.ts index 79ff9dd03ccd1..bd6adee686bad 100644 --- a/src/vs/workbench/services/views/browser/viewDescriptorService.ts +++ b/src/vs/workbench/services/views/browser/viewDescriptorService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ViewContainerLocation, IViewDescriptorService, ViewContainer, IViewsRegistry, IViewContainersRegistry, IViewDescriptor, Extensions as ViewExtensions, ViewVisibilityState, defaultViewIcon, ViewContainerLocationToString, VIEWS_LOG_ID, VIEWS_LOG_NAME, WindowVisibility } from '../../../common/views.js'; +import { ViewContainerLocation, IViewDescriptorService, ViewContainer, IViewsRegistry, IViewContainersRegistry, IViewDescriptor, Extensions as ViewExtensions, ViewVisibilityState, defaultViewIcon, ViewContainerLocationToString, VIEWS_LOG_ID, VIEWS_LOG_NAME, WindowEnablement } from '../../../common/views.js'; import { IContextKey, RawContextKey, IContextKeyService, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IExtensionService } from '../../extensions/common/extensions.js'; @@ -67,7 +67,7 @@ export class ViewDescriptorService extends Disposable implements IViewDescriptor private readonly _onDidChangeViewContainers = this._register(new Emitter<{ added: ReadonlyArray<{ container: ViewContainer; location: ViewContainerLocation }>; removed: ReadonlyArray<{ container: ViewContainer; location: ViewContainerLocation }> }>()); readonly onDidChangeViewContainers = this._onDidChangeViewContainers.event; - get viewContainers(): ReadonlyArray { return this.viewContainersRegistry.all; } + get viewContainers(): ReadonlyArray { return this.viewContainersRegistry.all.filter(vc => this.isViewContainerEnabled(vc)); } private readonly logger: Lazy; private readonly isSessionsWindow: boolean; @@ -108,11 +108,17 @@ export class ViewDescriptorService extends Disposable implements IViewDescriptor this._register(this.viewsRegistry.onDidChangeContainer(({ views, from, to }) => this.onDidChangeDefaultContainer(views, from, to))); this._register(this.viewContainersRegistry.onDidRegister(({ viewContainer }) => { + if (!this.isViewContainerEnabled(viewContainer)) { + return; + } this.onDidRegisterViewContainer(viewContainer); this._onDidChangeViewContainers.fire({ added: [{ container: viewContainer, location: this.getViewContainerLocation(viewContainer) }], removed: [] }); })); this._register(this.viewContainersRegistry.onDidDeregister(({ viewContainer, viewContainerLocation }) => { + if (!this.isViewContainerEnabled(viewContainer)) { + return; + } this.onDidDeregisterViewContainer(viewContainer); this._onDidChangeViewContainers.fire({ removed: [{ container: viewContainer, location: viewContainerLocation }], added: [] }); })); @@ -148,7 +154,7 @@ export class ViewDescriptorService extends Disposable implements IViewDescriptor private registerGroupedViews(groupedViews: Map): void { for (const [containerId, views] of groupedViews.entries()) { - const viewContainer = this.viewContainersRegistry.get(containerId); + const viewContainer = this.getViewContainerById(containerId); // The container has not been registered yet if (!viewContainer || !this.viewContainerModels.has(viewContainer)) { @@ -173,7 +179,7 @@ export class ViewDescriptorService extends Disposable implements IViewDescriptor private deregisterGroupedViews(groupedViews: Map): void { for (const [viewContainerId, views] of groupedViews.entries()) { - const viewContainer = this.viewContainersRegistry.get(viewContainerId); + const viewContainer = this.getViewContainerById(viewContainerId); // The container has not been registered yet if (!viewContainer || !this.viewContainerModels.has(viewContainer)) { @@ -187,7 +193,7 @@ export class ViewDescriptorService extends Disposable implements IViewDescriptor private moveOrphanViewsToDefaultLocation(): void { for (const [viewId, containerId] of this.viewDescriptorsCustomLocations.entries()) { // check if the view container exists - if (this.viewContainersRegistry.get(containerId)) { + if (this.getViewContainerById(containerId)) { continue; } @@ -268,7 +274,7 @@ export class ViewDescriptorService extends Disposable implements IViewDescriptor getViewDescriptorById(viewId: string): IViewDescriptor | null { const view = this.viewsRegistry.getView(viewId); - if (view && !this.isViewVisible(view)) { + if (view && !this.isViewEnabled(view)) { return null; } return view; @@ -286,14 +292,14 @@ export class ViewDescriptorService extends Disposable implements IViewDescriptor getViewContainerByViewId(viewId: string): ViewContainer | null { // Check if the view itself should be visible in current workspace const view = this.viewsRegistry.getView(viewId); - if (view && !this.isViewVisible(view)) { + if (view && !this.isViewEnabled(view)) { return null; } const containerId = this.viewDescriptorsCustomLocations.get(viewId); return containerId ? - this.viewContainersRegistry.get(containerId) ?? null : + this.getViewContainerById(containerId) : this.getDefaultContainerById(viewId); } @@ -324,36 +330,31 @@ export class ViewDescriptorService extends Disposable implements IViewDescriptor } getViewContainerById(id: string): ViewContainer | null { - const viewContainer = this.viewContainersRegistry.get(id) || null; - if (viewContainer && !this.isViewContainerVisible(viewContainer)) { - return null; - } - return viewContainer; + return this.viewContainers.find(vc => vc.id === id) ?? null; } getViewContainersByLocation(location: ViewContainerLocation): ViewContainer[] { - return this.viewContainers.filter(v => this.getViewContainerLocation(v) === location && this.isViewContainerVisible(v)); + return this.viewContainers.filter(v => this.getViewContainerLocation(v) === location); } - private isViewContainerVisible(viewContainer: ViewContainer): boolean { - const layoutVisibility = viewContainer.windowVisibility; - if (this.isSessionsWindow) { - return layoutVisibility === WindowVisibility.Sessions || layoutVisibility === WindowVisibility.Both; - } - return !layoutVisibility || layoutVisibility === WindowVisibility.Editor || layoutVisibility === WindowVisibility.Both; + private isViewContainerEnabled(viewContainer: ViewContainer): boolean { + return this.isEnabled(viewContainer.windowEnablement); + } + + private isViewEnabled(view: IViewDescriptor): boolean { + return this.isEnabled(view.windowEnablement); } - private isViewVisible(view: IViewDescriptor): boolean { - const layoutVisibility = view.windowVisibility; + private isEnabled(enablement: WindowEnablement | undefined): boolean { if (this.isSessionsWindow) { - return layoutVisibility === WindowVisibility.Sessions || layoutVisibility === WindowVisibility.Both; + return enablement === WindowEnablement.Sessions || enablement === WindowEnablement.Both; } - return !layoutVisibility || layoutVisibility === WindowVisibility.Editor || layoutVisibility === WindowVisibility.Both; + return !enablement || enablement === WindowEnablement.Editor || enablement === WindowEnablement.Both; } getDefaultViewContainer(location: ViewContainerLocation): ViewContainer | undefined { const viewContainers = this.viewContainersRegistry.getDefaultViewContainers(location); - return viewContainers.find(viewContainer => this.isViewContainerVisible(viewContainer)); + return viewContainers.find(viewContainer => this.isViewContainerEnabled(viewContainer)); } canMoveViews(): boolean { @@ -622,7 +623,7 @@ export class ViewDescriptorService extends Disposable implements IViewDescriptor const viewDescriptor = this.getViewDescriptorById(viewId); if (viewDescriptor) { const prevViewContainer = this.getViewContainerByViewId(viewId); - const newViewContainer = this.viewContainersRegistry.get(viewContainerId); + const newViewContainer = this.getViewContainerById(viewContainerId); if (prevViewContainer && newViewContainer && newViewContainer !== prevViewContainer) { viewsToMove.push({ views: [viewDescriptor], from: prevViewContainer, to: newViewContainer }); } From 18b4d8d9bccc2af48d43570ce1693c5ebab4cc45 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:19:47 +0000 Subject: [PATCH 18/32] Agents - update list of files in the multi-file diff editor (#312112) * Agents - update list of files in the multi-file diff editor * Refactor the source resolver --- .../browser/changesMultiDiffSourceResolver.ts | 105 ++++++++++++++++++ .../contrib/changes/browser/changesView.ts | 62 ++++++----- 2 files changed, 137 insertions(+), 30 deletions(-) create mode 100644 src/vs/sessions/contrib/changes/browser/changesMultiDiffSourceResolver.ts diff --git a/src/vs/sessions/contrib/changes/browser/changesMultiDiffSourceResolver.ts b/src/vs/sessions/contrib/changes/browser/changesMultiDiffSourceResolver.ts new file mode 100644 index 0000000000000..d4ad3c8729af8 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/changesMultiDiffSourceResolver.ts @@ -0,0 +1,105 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { derivedObservableWithCache, derivedOpts, ValueWithChangeEventFromObservable } from '../../../../base/common/observable.js'; +import { equals as arraysEqual } from '../../../../base/common/arrays.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { comparePaths } from '../../../../base/common/comparers.js'; +import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { IMultiDiffSourceResolver, IMultiDiffSourceResolverService, IResolvedMultiDiffSource, MultiDiffEditorItem } from '../../../../workbench/contrib/multiDiffEditor/browser/multiDiffSourceResolverService.js'; +import { ISessionFileChange } from '../../../services/sessions/common/session.js'; +import { ChangesViewModel } from './changesViewModel.js'; + +const CHANGES_MULTI_DIFF_SOURCE_SCHEME = 'changes-multi-diff-source'; + +interface ChangesMultiDiffUriFields { + readonly sessionResource: string; +} + +/** + * Build the multi-diff source URI for a session. The URI is used to identify + * the multi-diff editor so subsequent opens with the same session reuse the + * same input while the resource list updates reactively. + */ +export function getChangesMultiDiffSourceUri(sessionResource: URI): URI { + return URI.from({ + scheme: CHANGES_MULTI_DIFF_SOURCE_SCHEME, + query: JSON.stringify({ sessionResource: sessionResource.toString() } satisfies ChangesMultiDiffUriFields), + }); +} + +function parseUri(uri: URI): { sessionResource: URI } | undefined { + if (uri.scheme !== CHANGES_MULTI_DIFF_SOURCE_SCHEME) { + return undefined; + } + + let query: ChangesMultiDiffUriFields; + try { + query = JSON.parse(uri.query) as ChangesMultiDiffUriFields; + } catch { + return undefined; + } + + if (typeof query !== 'object' || query === null || typeof query.sessionResource !== 'string') { + return undefined; + } + + return { sessionResource: URI.parse(query.sessionResource) }; +} + +function compareChanges(a: ISessionFileChange, b: ISessionFileChange): number { + const aPath = isIChatSessionFileChange2(a) ? a.uri.fsPath : a.modifiedUri.fsPath; + const bPath = isIChatSessionFileChange2(b) ? b.uri.fsPath : b.modifiedUri.fsPath; + return comparePaths(aPath, bPath); +} + +export class ChangesMultiDiffSourceResolver extends Disposable implements IMultiDiffSourceResolver { + + constructor( + private readonly _viewModel: ChangesViewModel, + @IMultiDiffSourceResolverService multiDiffSourceResolverService: IMultiDiffSourceResolverService + ) { + super(); + this._register(multiDiffSourceResolverService.registerResolver(this)); + } + + canHandleUri(uri: URI): boolean { + return parseUri(uri) !== undefined; + } + + async resolveDiffSource(uri: URI): Promise { + const parsed = parseUri(uri)!; + + const changesObs = derivedObservableWithCache({ + owner: this, + }, (reader, lastValue) => { + if (this._viewModel.activeSessionIsLoadingObs.read(reader)) { + return lastValue ?? []; + } + + const activeSessionResource = this._viewModel.activeSessionResourceObs.read(reader); + if (!activeSessionResource || !isEqual(activeSessionResource, parsed.sessionResource)) { + return lastValue ?? []; + } + + return this._viewModel.activeSessionChangesObs.read(reader); + }); + + const resourcesObs = derivedOpts({ + owner: this, + equalsFn: (a, b) => arraysEqual(a, b, (x, y) => + isEqual(x.originalUri, y.originalUri) && + isEqual(x.modifiedUri, y.modifiedUri)), + }, reader => { + const changes = changesObs.read(reader); + return [...changes].sort(compareChanges).map(change => + new MultiDiffEditorItem(change.originalUri, change.modifiedUri, change.modifiedUri)); + }); + + return { resources: new ValueWithChangeEventFromObservable(resourcesObs) }; + } +} diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index e58a1131abf3b..6ad3277cfa3ac 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -56,10 +56,12 @@ import { createFileIconThemableTreeContainerScope } from '../../../../workbench/ import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../../workbench/services/editor/common/editorService.js'; import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { IMultiDiffEditorOptions } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; +import { ChangesMultiDiffSourceResolver, getChangesMultiDiffSourceUri } from './changesMultiDiffSourceResolver.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion, ICodeReviewService, PRReviewStateKind } from '../../codeReview/browser/codeReviewService.js'; import { CIStatusWidget } from './checksWidget.js'; -import { COPILOT_CLOUD_SESSION_TYPE, GITHUB_REMOTE_FILE_SCHEME, ISessionFileChange, SessionStatus } from '../../../services/sessions/common/session.js'; +import { COPILOT_CLOUD_SESSION_TYPE, GITHUB_REMOTE_FILE_SCHEME, SessionStatus } from '../../../services/sessions/common/session.js'; import { Orientation } from '../../../../base/browser/ui/sash/sash.js'; import { IView, Sizing, SplitView } from '../../../../base/browser/ui/splitview/splitview.js'; import { Color } from '../../../../base/common/color.js'; @@ -73,8 +75,6 @@ import { ChangesViewModel } from './changesViewModel.js'; import { ResourceTree } from '../../../../base/common/resourceTree.js'; import { structuralEquals } from '../../../../base/common/equals.js'; import { compareFileNames, comparePaths } from '../../../../base/common/comparers.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; const $ = dom.$; @@ -273,7 +273,6 @@ export class ChangesViewPane extends ViewPane { @IOpenerService openerService: IOpenerService, @IThemeService themeService: IThemeService, @IHoverService hoverService: IHoverService, - @ICommandService private readonly commandService: ICommandService, @IEditorService private readonly editorService: IEditorService, @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, @ILabelService private readonly labelService: ILabelService, @@ -285,6 +284,10 @@ export class ChangesViewPane extends ViewPane { this.viewModel = this.instantiationService.createInstance(ChangesViewModel); this._register(this.viewModel); + // Multi-diff editor source resolver + const changesMultiDiffSourceResolver = this.instantiationService.createInstance(ChangesMultiDiffSourceResolver, this.viewModel); + this._register(changesMultiDiffSourceResolver); + // Context keys this.isMergeBaseBranchProtectedContextKey = ActiveSessionContextKeys.IsMergeBaseBranchProtected.bindTo(this.scopedContextKeyService); this.isolationModeContextKey = ActiveSessionContextKeys.IsolationMode.bindTo(this.scopedContextKeyService); @@ -1019,33 +1022,32 @@ export class ChangesViewPane extends ViewPane { return; } - const compare = (aChange: ISessionFileChange, bChange: ISessionFileChange): number => { - const aPath = isIChatSessionFileChange2(aChange) ? aChange.uri.fsPath : aChange.modifiedUri.fsPath; - const bPath = isIChatSessionFileChange2(bChange) ? bChange.uri.fsPath : bChange.modifiedUri.fsPath; - return comparePaths(aPath, bPath); - }; - - // Sort the changes - const resources = changes.toSorted(compare).map(d => ({ - originalUri: d.originalUri, - modifiedUri: d.modifiedUri - })); - - // Ensure the reveal resource is part of the changes - reveal = reveal - ? resources.some(r => isEqual(r.modifiedUri, reveal)) - ? reveal - : undefined - : undefined; + // Determine the reveal target (original/modified URI pair) from the + // current change list, so the multi-diff editor can navigate to it. + let options: IMultiDiffEditorOptions | undefined; + if (reveal) { + const target = changes.find(c => isEqual(c.modifiedUri, reveal)); + if (target) { + options = { + viewState: { + revealData: { + resource: { + original: target.originalUri, + modified: target.modifiedUri, + }, + }, + }, + } satisfies IMultiDiffEditorOptions; + } + } - // Open multi-file diff editor - await this.commandService.executeCommand('_workbench.openMultiDiffEditor', { - multiDiffSourceUri: sessionResource.with({ scheme: sessionResource.scheme + '-session-changes' }), - title: localize('sessions.changes.title', 'Session Changes'), - resources, - reveal: reveal - ? { modifiedUri: reveal } - : undefined + // Open the multi-diff editor using the sessions source URI. The resource + // list is resolved via `SessionsMultiDiffSourceResolver` and updates + // reactively as `activeSessionChangesObs` changes. + await this.editorService.openEditor({ + multiDiffSource: getChangesMultiDiffSourceUri(sessionResource), + label: localize('sessions.changes.title', 'Session Changes'), + options, }); } From 32ca9719c90b527b0ffa96b288bd2964256e5ac3 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 23 Apr 2026 14:57:50 +0200 Subject: [PATCH 19/32] Keep active chat tab visible on resize and tab switch (#312124) Use scrollIntoView on the active tab when it changes, and add a ResizeObserver on the tabs container so the active tab stays visible when the chat composite bar is resized. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/browser/parts/chatCompositeBar.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/browser/parts/chatCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts index 99f4919fdf7ec..c7df14a32d252 100644 --- a/src/vs/sessions/browser/parts/chatCompositeBar.ts +++ b/src/vs/sessions/browser/parts/chatCompositeBar.ts @@ -6,7 +6,7 @@ import './media/chatCompositeBar.css'; import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; -import { $, addDisposableListener, EventType, getWindow, reset } from '../../../base/browser/dom.js'; +import { $, addDisposableListener, DisposableResizeObserver, EventType, getWindow, reset } from '../../../base/browser/dom.js'; import { autorun } from '../../../base/common/observable.js'; import { IThemeService } from '../../../platform/theme/common/themeService.js'; import { PANEL_ACTIVE_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_INACTIVE_TITLE_FOREGROUND } from '../../../workbench/common/theme.js'; @@ -79,6 +79,10 @@ export class ChatCompositeBar extends Disposable { this._rebuildTabs(chats, activeChatUri, mainChatUri); })); + // Scroll active tab into view on resize + const resizeObserver = this._register(new DisposableResizeObserver(() => this._revealActiveTab())); + this._register(resizeObserver.observe(this._tabsContainer)); + this._updateStyles(); this._register(this._themeService.onDidColorThemeChange(() => this._updateStyles())); @@ -195,9 +199,17 @@ export class ChatCompositeBar extends Disposable { const isActive = tab.chat.resource.toString() === activeChatId; tab.element.classList.toggle('active', isActive); tab.element.setAttribute('aria-selected', String(isActive)); + if (isActive) { + tab.element.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + } } } + private _revealActiveTab(): void { + const activeTab = this._tabs.find(t => t.element.classList.contains('active')); + activeTab?.element.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + } + private _updateVisibility(): void { // Show when there are multiple sessions, hide when there is only one (or none) const wasVisible = this._visible; From e923ba6a9e9ca73613ae50ba7cebcc8127beb5d6 Mon Sep 17 00:00:00 2001 From: Alex Ross <38270282+alexr00@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:23:40 +0200 Subject: [PATCH 20/32] Add telemetry for assignable users (#312104) * Add telemetry for assignable users Co-authored-by: Copilot * CCR feedback Co-authored-by: Copilot --------- Co-authored-by: Copilot --- .../github/common/octoKitServiceImpl.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/extensions/copilot/src/platform/github/common/octoKitServiceImpl.ts b/extensions/copilot/src/platform/github/common/octoKitServiceImpl.ts index 2141191e9dec1..2e7de8a268a14 100644 --- a/extensions/copilot/src/platform/github/common/octoKitServiceImpl.ts +++ b/extensions/copilot/src/platform/github/common/octoKitServiceImpl.ts @@ -466,6 +466,7 @@ export class OctoKitService extends BaseOctoKitService implements IOctoKitServic throw new PermissiveAuthRequiredError(); } + let usedSuggestedActors = true; try { // Try suggestedActors first (preferred API) const actors = await getAssignableActorsWithSuggestedActors( @@ -484,6 +485,7 @@ export class OctoKitService extends BaseOctoKitService implements IOctoKitServic // Fall back to assignableUsers for older GitHub Enterprise Server instances this._logService.trace('Falling back to assignableUsers API'); + usedSuggestedActors = false; return await getAssignableActorsWithAssignableUsers( this._fetcherService, this._logService, @@ -495,6 +497,21 @@ export class OctoKitService extends BaseOctoKitService implements IOctoKitServic ); } catch (e) { this._logService.error(`Error fetching assignable actors: ${e}`); + const properties: { errorCode?: string; usedSuggestedActors: string } = { + usedSuggestedActors: String(usedSuggestedActors), + }; + const errorCode = getErrorCode(e); + if (errorCode) { + properties.errorCode = errorCode; + } + + /* __GDPR__ + "pr.getAssignableUsersFailed" : { + "errorCode": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" }, + "usedSuggestedActors": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" } + } + */ + this._telemetryService.sendMSFTTelemetryErrorEvent('pr.getAssignableUsersFailed', properties); return []; } } @@ -538,3 +555,43 @@ export class OctoKitService extends BaseOctoKitService implements IOctoKitServic } } } + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export function getErrorCode(e: unknown): string | undefined { + if (!isObject(e)) { + return undefined; + } + + if (e.status !== undefined) { + return String(e.status); + } + + const networkError = e.networkError; + if (isObject(networkError) && networkError.statusCode !== undefined) { + return String(networkError.statusCode); + } + + const graphQLErrors = e.graphQLErrors; + if (Array.isArray(graphQLErrors)) { + const firstGraphQLError = graphQLErrors[0]; + if (isObject(firstGraphQLError)) { + const extensions = firstGraphQLError.extensions; + if (isObject(extensions) && extensions.code !== undefined) { + return String(extensions.code); + } + } + } + + if (e.code !== undefined) { + return String(e.code); + } + + if (typeof e.name === 'string' && e.name) { + return e.name; + } + + return undefined; +} From b9e691af27a2b8856ba6a6ffd264818fd4e089b6 Mon Sep 17 00:00:00 2001 From: Johannes Date: Thu, 23 Apr 2026 15:51:04 +0200 Subject: [PATCH 21/32] inlineChat: remove hover renderMode experiment and InlineChatInputWidget - Delete InlineChatSessionOverlayWidget (hover mode rendering) - Delete InlineChatInputWidget and InlineChatAffordance.showMenuAtSelection() The floating input popup is no longer needed; AskInChatAction now directly opens the chat panel and attaches the editor selection - Delete InlineChatHistoryService (only used by InlineChatInputWidget) - Delete SubmitInlineChatInputAction, HideInlineChatInputAction, QueueInChatAction - Remove RenderMode config key and CTX_HOVER_MODE context key - Remove CTX_INLINE_CHAT_INPUT_HAS_TEXT, CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED - Remove MenuId.InlineChatInput - Clean up inlineChatController: remove #renderMode observable, #runHover(), #resolveModelId(), #buildLocationData(), inputWidget accessor - Clean up inlineChatAffordance.fixture.ts: remove InlineChatOverlay fixture --- src/vs/platform/actions/common/actions.ts | 2 +- .../browser/actions/chatExecuteActions.ts | 2 - .../chatEditing/chatEditingEditorActions.ts | 6 - .../browser/inlineChat.contribution.ts | 7 +- .../inlineChat/browser/inlineChatActions.ts | 189 +---- .../browser/inlineChatAffordance.ts | 59 +- .../browser/inlineChatController.ts | 271 +------ .../browser/inlineChatHistoryService.ts | 97 --- .../browser/inlineChatOverlayWidget.ts | 693 ------------------ .../browser/media/inlineChatOverlayWidget.css | 231 ------ .../contrib/inlineChat/common/inlineChat.ts | 19 - .../test/browser/inlineChatAffordance.test.ts | 22 +- .../test/browser/inlineChatController.test.ts | 336 --------- .../test/browser/inlineChatZoneMenus.test.ts | 32 +- .../editor/inlineChatAffordance.fixture.ts | 93 +-- 15 files changed, 48 insertions(+), 2011 deletions(-) delete mode 100644 src/vs/workbench/contrib/inlineChat/browser/inlineChatHistoryService.ts delete mode 100644 src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts delete mode 100644 src/vs/workbench/contrib/inlineChat/browser/media/inlineChatOverlayWidget.css delete mode 100644 src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index f4ac668759d06..f4db3db735160 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -295,7 +295,7 @@ export class MenuId { static readonly ChatEditorInlineExecute = new MenuId('ChatEditorInputExecute'); static readonly ChatEditorInlineInputSide = new MenuId('ChatEditorInputSide'); static readonly InlineChatEditorAffordance = new MenuId('InlineChatEditorAffordance'); - static readonly InlineChatInput = new MenuId('InlineChatInput'); + static readonly AccessibleView = new MenuId('AccessibleView'); static readonly MultiDiffEditorContent = new MenuId('MultiDiffEditorContent'); static readonly MultiDiffEditorFileToolbar = new MenuId('MultiDiffEditorFileToolbar'); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 46107d6555bed..765f80e7299f9 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -39,7 +39,6 @@ import { getEditingSessionContext } from '../chatEditing/chatEditingActions.js'; import { ctxHasEditorModification, ctxHasRequestInProgress, ctxIsGlobalEditingSession } from '../chatEditing/chatEditingEditorContextKeys.js'; import { ACTION_ID_NEW_CHAT, CHAT_CATEGORY, clearChatSessionPreservingType, handleCurrentEditingSession, handleModeSwitch } from './chatActions.js'; import { CreateRemoteAgentJobAction } from './chatContinueInAction.js'; -import { CTX_HOVER_MODE } from '../../../inlineChat/common/inlineChat.js'; export interface IVoiceChatExecuteActionContext { readonly disableTimeout?: boolean; @@ -927,7 +926,6 @@ export class CancelAction extends Action2 { when: ContextKeyExpr.and( ctxIsGlobalEditingSession.negate(), ctxHasRequestInProgress, - CTX_HOVER_MODE.negate(), ), order: 4, group: 'navigation', diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts index e539ca72eef51..8233bd804f547 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts @@ -24,7 +24,6 @@ import { EditorResourceAccessor, SideBySideEditor, TEXT_DIFF_EDITOR_ID } from '. import { EditorInput } from '../../../../common/editor/editorInput.js'; import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { ACTIVE_GROUP, IEditorService } from '../../../../services/editor/common/editorService.js'; -import { CTX_HOVER_MODE } from '../../../inlineChat/common/inlineChat.js'; import { MultiDiffEditor } from '../../../multiDiffEditor/browser/multiDiffEditor.js'; import { IDocumentDiffItemWithMultiDiffEditorItem, MultiDiffEditorInput } from '../../../multiDiffEditor/browser/multiDiffEditorInput.js'; import { NOTEBOOK_CELL_LIST_FOCUSED, NOTEBOOK_EDITOR_FOCUSED } from '../../../notebook/common/notebookContextKeys.js'; @@ -332,11 +331,6 @@ class ToggleDiffAction extends ChatEditingEditorAction { group: 'a_resolve', order: 2, when: ContextKeyExpr.and(ctxReviewModeEnabled) - }, { - id: MenuId.ChatEditorInlineExecute, - group: 'a_resolve', - order: 2, - when: ContextKeyExpr.and(ctxReviewModeEnabled, CTX_HOVER_MODE) }] }); } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts index acb788bced6b8..8073fa472f1f6 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts @@ -17,7 +17,7 @@ import { InlineChatNotebookContribution } from './inlineChatNotebook.js'; import { IWorkbenchContributionsRegistry, registerWorkbenchContribution2, Extensions as WorkbenchExtensions, WorkbenchPhase } from '../../../common/contributions.js'; import { IInlineChatSessionService } from './inlineChatSessionService.js'; import { InlineChatEnabler, InlineChatEscapeToolContribution, InlineChatSessionServiceImpl } from './inlineChatSessionServiceImpl.js'; -import { IInlineChatHistoryService, InlineChatHistoryService } from './inlineChatHistoryService.js'; + import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { CancelAction, ChatSubmitAction } from '../../chat/browser/actions/chatExecuteActions.js'; import { localize } from '../../../../nls.js'; @@ -28,7 +28,6 @@ import { InlineChatAccessibilityHelp } from './inlineChatAccessibilityHelp.js'; registerEditorContribution(InlineChatController.ID, InlineChatController, EditorContributionInstantiation.Eager); // EAGER because of notebook dispose/create of editors registerAction2(InlineChatActions.KeepSessionAction2); -registerAction2(InlineChatActions.UndoSessionAction2); registerAction2(InlineChatActions.UndoAndCloseSessionAction2); registerAction2(InlineChatActions.CancelSessionAction); registerAction2(InlineChatActions.ContinueInlineChatInChatViewAction); @@ -37,7 +36,6 @@ registerAction2(InlineChatActions.RephraseInlineChatSessionAction); // --- browser registerSingleton(IInlineChatSessionService, InlineChatSessionServiceImpl, InstantiationType.Delayed); -registerSingleton(IInlineChatHistoryService, InlineChatHistoryService, InstantiationType.Delayed); // --- MENU special --- @@ -96,9 +94,6 @@ MenuRegistry.appendMenuItem(MENU_INLINE_CHAT_WIDGET_STATUS, cancelActionMenuItem registerAction2(InlineChatActions.StartSessionAction); registerAction2(InlineChatActions.AskInChatAction); registerAction2(InlineChatActions.FocusInlineChat); -registerAction2(InlineChatActions.SubmitInlineChatInputAction); -registerAction2(InlineChatActions.QueueInChatAction); -registerAction2(InlineChatActions.HideInlineChatInputAction); registerAction2(InlineChatActions.FixDiagnosticsAction); registerAction2(InlineChatActions.DismissEditorAffordanceAction); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts index 2c244736af384..86516c54602c9 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts @@ -11,7 +11,7 @@ import { EmbeddedDiffEditorWidget } from '../../../../editor/browser/widget/diff import { EmbeddedCodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/embeddedCodeEditorWidget.js'; import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; import { InlineChatController, InlineChatRunOptions } from './inlineChatController.js'; -import { ACTION_ACCEPT_CHANGES, ACTION_ASK_IN_CHAT, CTX_INLINE_CHAT_FOCUSED, CTX_INLINE_CHAT_VISIBLE, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, CTX_INLINE_CHAT_POSSIBLE, ACTION_START, CTX_INLINE_CHAT_V2_ENABLED, CTX_INLINE_CHAT_V1_ENABLED, CTX_HOVER_MODE, CTX_INLINE_CHAT_INPUT_HAS_TEXT, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT, CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED, CTX_INLINE_CHAT_PENDING_CONFIRMATION, CTX_INLINE_CHAT_TERMINATED, InlineChatConfigKeys, CTX_FIX_DIAGNOSTICS_ENABLED, CTX_INLINE_CHAT_AFFORDANCE_VISIBLE, CTX_ASK_IN_CHAT_ENABLED } from '../common/inlineChat.js'; +import { ACTION_ACCEPT_CHANGES, ACTION_ASK_IN_CHAT, CTX_INLINE_CHAT_FOCUSED, CTX_INLINE_CHAT_VISIBLE, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, CTX_INLINE_CHAT_POSSIBLE, ACTION_START, CTX_INLINE_CHAT_V2_ENABLED, CTX_INLINE_CHAT_V1_ENABLED, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT, CTX_INLINE_CHAT_TERMINATED, CTX_FIX_DIAGNOSTICS_ENABLED, CTX_INLINE_CHAT_AFFORDANCE_VISIBLE, CTX_ASK_IN_CHAT_ENABLED } from '../common/inlineChat.js'; import { ctxHasEditorModification, ctxHasRequestInProgress } from '../../chat/browser/chatEditing/chatEditingEditorContextKeys.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, IAction2Options, MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js'; @@ -25,10 +25,9 @@ import { CommandsRegistry } from '../../../../platform/commands/common/commands. import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IChatEditingService } from '../../chat/common/editing/chatEditingService.js'; import { IChatWidgetService } from '../../chat/browser/chat.js'; -import { ChatRequestQueueKind } from '../../chat/common/chatService/chatService.js'; + import { ChatEntitlementContextKeys } from '../../../services/chat/common/chatEntitlementService.js'; CommandsRegistry.registerCommandAlias('interactiveEditor.start', 'inlineChat.start'); @@ -101,8 +100,6 @@ export class StartSessionAction extends Action2 { private async _runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor, ...args: unknown[]) { - const configServce = accessor.get(IConfigurationService); - const ctrl = InlineChatController.get(editor); if (!ctrl) { return; @@ -114,17 +111,6 @@ export class StartSessionAction extends Action2 { options = arg; } - // use hover overlay to ask for input - if (!options?.message && configServce.getValue(InlineChatConfigKeys.RenderMode) === 'hover') { - const selection = editor.getSelection(); - const placeholder = selection && !selection.isEmpty() - ? localize('placeholderWithSelection', "Describe how to change this") - : localize('placeholderNoSelection', "Describe what to generate"); - // show menu and RETURN because the menu is re-entrant - await ctrl.inputOverlayWidget.showMenuAtSelection(placeholder); - return; - } - await ctrl?.run({ ...options }); } } @@ -265,7 +251,6 @@ export class FixDiagnosticsAction extends AbstractInlineChatAction { } override runInlineChatCommand(_accessor: ServicesAccessor, ctrl: InlineChatController, _editor: ICodeEditor, ..._args: unknown[]): void { - ctrl.inputWidget.hide(); ctrl.run({ autoSend: true, attachDiagnostics: true }); } } @@ -322,37 +307,6 @@ export class KeepSessionAction2 extends KeepOrUndoSessionAction { } } -export class UndoSessionAction2 extends KeepOrUndoSessionAction { - - constructor() { - super(false, { - id: 'inlineChat2.undo', - title: localize2('undo', "Undo"), - f1: true, - icon: Codicon.discard, - precondition: ContextKeyExpr.and(CTX_INLINE_CHAT_VISIBLE, CTX_HOVER_MODE), - keybinding: [{ - when: ContextKeyExpr.or( - ContextKeyExpr.and(EditorContextKeys.focus, ctxHasEditorModification.negate()), - ChatContextKeys.inputHasFocus, - ), - weight: KeybindingWeight.WorkbenchContrib + 1, - primary: KeyCode.Escape, - }], - menu: [{ - id: MenuId.ChatEditorInlineExecute, - group: 'navigation', - order: 100, - when: ContextKeyExpr.and( - CTX_HOVER_MODE, - ctxHasRequestInProgress.negate(), - ctxHasEditorModification, - ) - }] - }); - } -} - export class UndoAndCloseSessionAction2 extends KeepOrUndoSessionAction { constructor() { @@ -374,11 +328,6 @@ export class UndoAndCloseSessionAction2 extends KeepOrUndoSessionAction { id: MenuId.ChatEditorInlineExecute, group: 'navigation', order: 100, - when: ContextKeyExpr.or( - CTX_HOVER_MODE.negate(), - ContextKeyExpr.and(CTX_HOVER_MODE, ctxHasEditorModification.negate(), ctxHasRequestInProgress.negate()), - ContextKeyExpr.and(CTX_HOVER_MODE, CTX_INLINE_CHAT_PENDING_CONFIRMATION) - ) }] }); } @@ -399,12 +348,7 @@ export class CancelSessionAction extends KeepOrUndoSessionAction { weight: KeybindingWeight.WorkbenchContrib + 1, primary: KeyCode.Escape, }], - menu: [{ - id: MenuId.ChatEditorInlineExecute, - group: 'navigation', - order: 100, - when: ContextKeyExpr.and(CTX_HOVER_MODE, ctxHasRequestInProgress) - }] + menu: [] }); } } @@ -452,58 +396,6 @@ export class RephraseInlineChatSessionAction extends AbstractInlineChatAction { } } -export class SubmitInlineChatInputAction extends AbstractInlineChatAction { - - constructor() { - super({ - id: 'inlineChat.submitInput', - title: localize2('submitInput', "Send"), - icon: Codicon.send, - precondition: ContextKeyExpr.and(CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED, CTX_INLINE_CHAT_INPUT_HAS_TEXT, ContextKeyExpr.or(CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.negate(), CTX_ASK_IN_CHAT_ENABLED.negate())), - keybinding: { - when: ContextKeyExpr.and(CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED, ContextKeyExpr.or(CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.negate(), CTX_ASK_IN_CHAT_ENABLED.negate())), - weight: KeybindingWeight.EditorCore + 10, - primary: KeyCode.Enter - }, - menu: [{ - id: MenuId.InlineChatInput, - group: '0_main', - order: 1, - when: ContextKeyExpr.or(CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.negate(), CTX_ASK_IN_CHAT_ENABLED.negate()) - }] - }); - } - - override runInlineChatCommand(_accessor: ServicesAccessor, ctrl: InlineChatController, _editor: ICodeEditor, ..._args: unknown[]): void { - const value = ctrl.inputWidget.value; - if (value) { - ctrl.inputWidget.addToHistory(value); - ctrl.inputWidget.hide(); - ctrl.run({ message: value, autoSend: true }); - } - } -} - -export class HideInlineChatInputAction extends AbstractInlineChatAction { - - constructor() { - super({ - id: 'inlineChat.hideInput', - title: localize2('hideInput', "Hide Input"), - precondition: CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED, - keybinding: { - when: CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED, - weight: KeybindingWeight.EditorCore + 10, - primary: KeyCode.Escape - } - }); - } - - override runInlineChatCommand(_accessor: ServicesAccessor, ctrl: InlineChatController, _editor: ICodeEditor, ..._args: unknown[]): void { - ctrl.inputWidget.hide(); - } -} - export class AskInChatAction extends EditorAction2 { @@ -536,13 +428,21 @@ export class AskInChatAction extends EditorAction2 { override async runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor) { const chatEditingService = accessor.get(IChatEditingService); - const ctrl = InlineChatController.get(editor); - if (!ctrl || !editor.hasModel()) { + const chatWidgetService = accessor.get(IChatWidgetService); + if (!editor.hasModel()) { + return; + } + const session = chatEditingService.editingSessionsObs.get().find(s => s.getEntry(editor.getModel().uri)); + if (!session) { + return; + } + const widget = await chatWidgetService.openSession(session.chatSessionResource); + if (!widget) { return; } - const entry = chatEditingService.editingSessionsObs.get().find(value => value.getEntry(editor.getModel().uri)); - if (entry) { - ctrl.inputOverlayWidget.showMenuAtSelection(localize('placeholderAskInChat', "Describe how to proceed in Chat")); + const selection = editor.getSelection(); + if (selection && !selection.isEmpty()) { + await widget.attachmentModel.addFile(editor.getModel().uri, selection); } } } @@ -566,60 +466,3 @@ export class DismissEditorAffordanceAction extends EditorAction2 { InlineChatController.get(editor)?.inputOverlayWidget.dismiss(); } } - -export class QueueInChatAction extends AbstractInlineChatAction { - - - constructor() { - super({ - id: 'inlineChat.queueInChat', - title: localize2('queueInChat', "Queue in Chat"), - icon: Codicon.arrowUp, - precondition: ContextKeyExpr.and(CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED, CTX_INLINE_CHAT_INPUT_HAS_TEXT, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT, CTX_ASK_IN_CHAT_ENABLED), - keybinding: { - when: ContextKeyExpr.and(CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT, CTX_ASK_IN_CHAT_ENABLED), - weight: KeybindingWeight.EditorCore + 10, - primary: KeyCode.Enter - }, - menu: [{ - id: MenuId.InlineChatInput, - group: '0_main', - order: 1, - when: ContextKeyExpr.and(CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT, CTX_ASK_IN_CHAT_ENABLED), - }] - }); - } - - override async runInlineChatCommand(accessor: ServicesAccessor, ctrl: InlineChatController, editor: ICodeEditor): Promise { - const chatEditingService = accessor.get(IChatEditingService); - const chatWidgetService = accessor.get(IChatWidgetService); - if (!editor.hasModel()) { - return; - } - - const value = ctrl.inputWidget.value; - if (value) { - ctrl.inputWidget.addToHistory(value); - } - ctrl.inputWidget.hide(); - if (!value) { - return; - } - - const session = chatEditingService.editingSessionsObs.get().find(s => s.getEntry(editor.getModel().uri)); - if (!session) { - return; - } - - const widget = await chatWidgetService.openSession(session.chatSessionResource); - if (!widget) { - return; - } - - const selection = editor.getSelection(); - if (selection && !selection.isEmpty()) { - await widget.attachmentModel.addFile(editor.getModel().uri, selection); - } - await widget.acceptInput(value, { queue: ChatRequestQueueKind.Queued }); - } -} diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts index 77a61703dc47d..074b8dbe3dbaf 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts @@ -4,19 +4,19 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../base/common/lifecycle.js'; -import { autorun, debouncedObservable, derived, observableSignalFromEvent, observableValue, runOnChange, waitForState } from '../../../../base/common/observable.js'; +import { autorun, debouncedObservable, derived, observableSignalFromEvent, observableValue, runOnChange } from '../../../../base/common/observable.js'; import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { observableCodeEditor } from '../../../../editor/browser/observableCodeEditor.js'; -import { ScrollType } from '../../../../editor/common/editorCommon.js'; + import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { InlineChatConfigKeys, CTX_INLINE_CHAT_AFFORDANCE_VISIBLE } from '../common/inlineChat.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; import { InlineChatEditorAffordance } from './inlineChatEditorAffordance.js'; -import { InlineChatInputWidget } from './inlineChatOverlayWidget.js'; -import { Selection, SelectionDirection } from '../../../../editor/common/core/selection.js'; -import { assertType } from '../../../../base/common/types.js'; + +import { Selection } from '../../../../editor/common/core/selection.js'; + import { CursorChangeReason } from '../../../../editor/common/cursorEvents.js'; import { IInlineChatSessionService } from './inlineChatSessionService.js'; import { CodeActionController } from '../../../../editor/contrib/codeAction/browser/codeActionController.js'; @@ -41,14 +41,11 @@ type InlineChatAffordanceClassification = { export class InlineChatAffordance extends Disposable { readonly #editor: ICodeEditor; - readonly #inputWidget: InlineChatInputWidget; readonly #instantiationService: IInstantiationService; - readonly #menuData = observableValue<{ rect: DOMRect; above: boolean; lineNumber: number; placeholder: string; value?: string } | undefined>(this, undefined); readonly #selectionData = observableValue(this, undefined); constructor( editor: ICodeEditor, - inputWidget: InlineChatInputWidget, @IInstantiationService instantiationService: IInstantiationService, @IConfigurationService configurationService: IConfigurationService, @IChatEntitlementService chatEntiteldService: IChatEntitlementService, @@ -58,7 +55,6 @@ export class InlineChatAffordance extends Disposable { ) { super(); this.#editor = editor; - this.#inputWidget = inputWidget; this.#instantiationService = instantiationService; const editorObs = observableCodeEditor(this.#editor); @@ -153,54 +149,9 @@ export class InlineChatAffordance extends Disposable { } })); - this._store.add(autorun(r => { - const data = this.#menuData.read(r); - if (!data) { - return; - } - - // Reveal the line in case it's outside the viewport (e.g., when triggered from sticky scroll) - this.#editor.revealLineInCenterIfOutsideViewport(data.lineNumber, ScrollType.Immediate); - - const editorDomNode = this.#editor.getDomNode()!; - const editorRect = editorDomNode.getBoundingClientRect(); - const left = data.rect.left - editorRect.left; - - // Show the overlay widget - this.#inputWidget.show(data.lineNumber, left, data.above, data.placeholder, data.value); - })); - - this._store.add(autorun(r => { - const pos = this.#inputWidget.position.read(r); - if (pos === null) { - this.#menuData.set(undefined, undefined); - } - })); } dismiss(): void { this.#selectionData.set(undefined, undefined); } - - async showMenuAtSelection(placeholder: string, value?: string): Promise { - assertType(this.#editor.hasModel()); - - const direction = this.#editor.getSelection().getDirection(); - const position = this.#editor.getPosition(); - const editorDomNode = this.#editor.getDomNode(); - const scrolledPosition = this.#editor.getScrolledVisiblePosition(position); - const editorRect = editorDomNode.getBoundingClientRect(); - const x = editorRect.left + scrolledPosition.left; - const y = editorRect.top + scrolledPosition.top; - - this.#menuData.set({ - rect: new DOMRect(x, y, 0, scrolledPosition.height), - above: direction === SelectionDirection.RTL, - lineNumber: position.lineNumber, - placeholder, - value - }, undefined); - - await waitForState(this.#inputWidget.position, pos => pos === null); - } } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts index c0d5bc398c465..84974401e3f67 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts @@ -42,18 +42,18 @@ import { IChatWidgetLocationOptions } from '../../chat/browser/widget/chatWidget import { IChatEditingService, ModifiedFileEntryState } from '../../chat/common/editing/chatEditingService.js'; import { ChatModel } from '../../chat/common/model/chatModel.js'; import { ChatMode } from '../../chat/common/chatModes.js'; -import { IChatLocationData, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../chat/common/chatService/chatService.js'; +import { IChatService, IChatToolInvocation, ToolConfirmKind } from '../../chat/common/chatService/chatService.js'; import { IChatRequestVariableEntry, IDiagnosticVariableEntryFilterData } from '../../chat/common/attachments/chatVariableEntries.js'; import { isResponseVM } from '../../chat/common/model/chatViewModel.js'; -import { ChatAgentLocation, ChatModeKind } from '../../chat/common/constants.js'; +import { ChatAgentLocation } from '../../chat/common/constants.js'; import { ILanguageModelChatMetadata, ILanguageModelChatSelector, ILanguageModelsService, isILanguageModelChatSelector } from '../../chat/common/languageModels.js'; import { isNotebookContainingCellEditor as isNotebookWithCellEditor } from '../../notebook/browser/notebookEditor.js'; import { INotebookEditorService } from '../../notebook/browser/services/notebookEditorService.js'; import { CellUri, ICellEditOperation } from '../../notebook/common/notebookCommon.js'; import { INotebookService } from '../../notebook/common/notebookService.js'; -import { CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT, CTX_INLINE_CHAT_PENDING_CONFIRMATION, CTX_INLINE_CHAT_TERMINATED, CTX_INLINE_CHAT_VISIBLE, InlineChatConfigKeys } from '../common/inlineChat.js'; +import { CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT, CTX_INLINE_CHAT_TERMINATED, CTX_INLINE_CHAT_VISIBLE, InlineChatConfigKeys } from '../common/inlineChat.js'; import { InlineChatAffordance } from './inlineChatAffordance.js'; -import { InlineChatInputWidget, InlineChatSessionOverlayWidget } from './inlineChatOverlayWidget.js'; + import { continueInPanelChat, IInlineChatSession2, IInlineChatSessionService, rephraseInlineChat } from './inlineChatSessionService.js'; import { EditorBasedInlineChatWidget } from './inlineChatWidget.js'; import { InlineChatZoneWidget } from './inlineChatZoneWidget.js'; @@ -116,10 +116,8 @@ export class InlineChatController implements IEditorContribution { readonly #store = new DisposableStore(); readonly #isActiveController = observableValue(this, false); - readonly #renderMode: IObservable<'zone' | 'hover'>; readonly #zone: Lazy; readonly inputOverlayWidget: InlineChatAffordance; - readonly #inputWidget: InlineChatInputWidget; readonly #currentSession: IObservable; @@ -146,10 +144,6 @@ export class InlineChatController implements IEditorContribution { return Boolean(this.#currentSession.get()); } - get inputWidget(): InlineChatInputWidget { - return this.#inputWidget; - } - constructor( editor: ICodeEditor, @IInstantiationService instaService: IInstantiationService, @@ -187,10 +181,8 @@ export class InlineChatController implements IEditorContribution { const ctxInlineChatVisible = CTX_INLINE_CHAT_VISIBLE.bindTo(contextKeyService); const ctxFileBelongsToChat = CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.bindTo(contextKeyService); - const ctxPendingConfirmation = CTX_INLINE_CHAT_PENDING_CONFIRMATION.bindTo(contextKeyService); const ctxTerminated = CTX_INLINE_CHAT_TERMINATED.bindTo(contextKeyService); const notebookAgentConfig = observableConfigValue(InlineChatConfigKeys.notebookAgent, false, this.#configurationService); - this.#renderMode = observableConfigValue(InlineChatConfigKeys.RenderMode, 'zone', this.#configurationService); // Track whether the current editor's file is being edited by any chat editing session this.#store.add(autorun(r => { @@ -216,9 +208,7 @@ export class InlineChatController implements IEditorContribution { ctxFileBelongsToChat.set(hasEdits); })); - const overlayWidget = this.#inputWidget = this.#store.add(this.#instaService.createInstance(InlineChatInputWidget, editorObs)); - const sessionOverlayWidget = this.#store.add(this.#instaService.createInstance(InlineChatSessionOverlayWidget, editorObs)); - this.inputOverlayWidget = this.#store.add(this.#instaService.createInstance(InlineChatAffordance, this.#editor, overlayWidget)); + this.inputOverlayWidget = this.#store.add(this.#instaService.createInstance(InlineChatAffordance, this.#editor)); this.#zone = new Lazy(() => { @@ -363,15 +353,11 @@ export class InlineChatController implements IEditorContribution { // HIDE/SHOW const session = visibleSessionObs.read(r); - const renderMode = this.#renderMode.read(r); if (!session) { this.#zone.rawValue?.hide(); this.#zone.rawValue?.widget.chatWidget.setModel(undefined); editor.focus(); ctxInlineChatVisible.reset(); - } else if (renderMode === 'hover') { - // hover mode: no zone widget needed, keep focus in editor - ctxInlineChatVisible.set(true); } else { ctxInlineChatVisible.set(true); this.#zone.value.widget.chatWidget.setModel(session.chatModel); @@ -385,32 +371,6 @@ export class InlineChatController implements IEditorContribution { } })); - // Show progress overlay widget in hover mode when a request is in progress or edits are not yet settled - this.#store.add(autorun(r => { - const session = visibleSessionObs.read(r); - const renderMode = this.#renderMode.read(r); - if (!session || renderMode !== 'hover') { - ctxPendingConfirmation.set(false); - sessionOverlayWidget.hide(); - return; - } - const lastRequest = session.chatModel.lastRequestObs.read(r); - const isInProgress = lastRequest?.response?.isInProgress.read(r); - const isPendingConfirmation = !!lastRequest?.response?.isPendingConfirmation.read(r); - const isError = !!lastRequest?.response?.result?.errorDetails; - const isTerminated = !!session.terminationState.read(r); - ctxPendingConfirmation.set(isPendingConfirmation); - const entry = session.editingSession.readEntry(session.uri, r); - // When there's no entry (no changes made) and the response is complete, the widget should be hidden. - // When there's an entry in Modified state, it needs to be settled (accepted/rejected). - const isNotSettled = entry ? entry.state.read(r) === ModifiedFileEntryState.Modified : false; - if (isInProgress || isNotSettled || isPendingConfirmation || isError || isTerminated) { - sessionOverlayWidget.show(session); - } else { - sessionOverlayWidget.hide(); - } - })); - // Auto-approve tool confirmations for inline chat. The user implicitly // consents to editing the current file by invoking inline chat on it, // even if the file qualifies as a sensitive file. @@ -475,7 +435,6 @@ export class InlineChatController implements IEditorContribution { const session = visibleSessionObs.read(r); const response = lastResponseObs.read(r); const terminationState = session?.terminationState.read(r); - const renderMode = this.#renderMode.read(r); this.#zone.rawValue?.widget.updateInfo(''); @@ -487,14 +446,11 @@ export class InlineChatController implements IEditorContribution { // ERROR case this.#zone.rawValue?.widget.updateInfo(`$(error) ${response.result.errorDetails.message}`); alert(response.result.errorDetails.message); - } else if (terminationState && renderMode === 'zone') { - // Zone mode: show termination card with message and action buttons - this.#zone.rawValue?.showTerminationCard(terminationState, this.#instaService); } else if (terminationState) { - this.#zone.rawValue?.widget.updateInfo(`$(info) ${renderAsPlaintext(terminationState)}`); + this.#zone.rawValue?.showTerminationCard(terminationState, this.#instaService); } - if (!terminationState || renderMode !== 'zone') { + if (!terminationState) { this.#zone.rawValue?.hideTerminationCard(); } @@ -579,106 +535,7 @@ export class InlineChatController implements IEditorContribution { this.#isActiveController.set(true, undefined); const session = this.#inlineChatSessionService.createSession(this.#editor); - - if (this.#renderMode.get() === 'hover') { - return this.#runHover(session, arg); - } else { - return this.#runZone(session, arg); - } - } - - /** - * Hover mode: submit requests directly via IChatService.sendRequest without - * instantiating the zone widget. - */ - async #runHover(session: IInlineChatSession2, arg?: InlineChatRunOptions): Promise { - assertType(this.#editor.hasModel()); - const uri = this.#editor.getModel().uri; - - - // Apply editor adjustments from args - if (arg && InlineChatRunOptions.isInlineChatRunOptions(arg)) { - if (arg.initialRange) { - this.#editor.revealRange(arg.initialRange); - } - if (arg.initialSelection) { - this.#editor.setSelection(arg.initialSelection); - } - } - - // Build location data (after selection adjustments) - const { location, locationData } = this.#buildLocationData(); - - // Resolve model - let userSelectedModelId: string | undefined; - if (arg?.modelSelector) { - userSelectedModelId = (await this.#languageModelService.selectLanguageModels(arg.modelSelector)).sort().at(0); - if (!userSelectedModelId) { - throw new Error(`No language models found matching selector: ${JSON.stringify(arg.modelSelector)}.`); - } - } else { - userSelectedModelId = await this.#resolveModelId(location); - } - - // Collect attachments - const attachedContext: IChatRequestVariableEntry[] = []; - if (arg?.attachments) { - for (const attachment of arg.attachments) { - const resolved = await this.#chatAttachmentResolveService.resolveImageEditorAttachContext(attachment); - if (resolved) { - attachedContext.push(resolved); - } - } - } - - // ADD diagnostics (only when explicitly requested) - if (arg?.attachDiagnostics) { - for (const [range, marker] of this.#markerDecorationsService.getLiveMarkers(uri)) { - if (range.intersectRanges(this.#editor.getSelection())) { - const filter = IDiagnosticVariableEntryFilterData.fromMarker(marker); - attachedContext.push(IDiagnosticVariableEntryFilterData.toEntry(filter)); - } - } - if (attachedContext.length > 0 && !arg.message) { - arg.message = attachedContext.length > 1 - ? localize('fixN', "Fix the attached problems") - : localize('fix1', "Fix the attached problem"); - } - } - - // Send the request directly - if (arg?.message && arg.autoSend) { - await this.#chatService.sendRequest( - session.chatModel.sessionResource, - arg.message, - { - userSelectedModelId, - location, - locationData, - attachedContext: attachedContext.length > 0 ? attachedContext : undefined, - modeInfo: { - kind: ChatModeKind.Ask, - isBuiltin: true, - modeInstructions: undefined, - modeId: 'ask', - applyCodeBlockSuggestionId: undefined, - }, - } - ); - } - - if (!arg?.resolveOnResponse) { - await Event.toPromise(session.editingSession.onDidDispose); - const rejected = session.editingSession.getEntry(uri)?.state.get() === ModifiedFileEntryState.Rejected; - return !rejected; - } else { - const modifiedObs = derived(r => { - const entry = session.editingSession.readEntry(uri, r); - return entry?.state.read(r) === ModifiedFileEntryState.Modified && !entry?.isCurrentlyBeingModifiedBy.read(r); - }); - await waitForState(modifiedObs, state => state === true); - return true; - } + return this.#runZone(session, arg); } /** @@ -805,29 +662,13 @@ export class InlineChatController implements IEditorContribution { return; } - if (this.#renderMode.get() === 'zone') { - // Zone mode: clear termination state and restore input text in the chat widget. - // The autorun watching terminationState will flip the card back automatically. - const requestText = this.#instaService.invokeFunction(rephraseInlineChat, session); - if (requestText) { - this.#zone.rawValue?.widget.chatWidget.setInput(requestText); - } - this.#zone.rawValue?.widget.focus(); - return; - } - - const requestText = session.chatModel.getRequests().at(-1)?.message.text; - session.dispose(); - - if (!requestText) { - return; + // Clear termination state and restore input text in the chat widget. + // The autorun watching terminationState will flip the card back automatically. + const requestText = this.#instaService.invokeFunction(rephraseInlineChat, session); + if (requestText) { + this.#zone.rawValue?.widget.chatWidget.setInput(requestText); } - - const selection = this.#editor.getSelection(); - const placeholder = selection && !selection.isEmpty() - ? localize('placeholderWithSelectionHover', "Describe how to change this") - : localize('placeholderNoSelectionHover', "Describe what to generate"); - await this.inputOverlayWidget.showMenuAtSelection(placeholder, requestText); + this.#zone.rawValue?.widget.focus(); } async #selectVendorDefaultModel(session: IInlineChatSession2): Promise { @@ -844,90 +685,6 @@ export class InlineChatController implements IEditorContribution { } } - /** - * Resolves the language model identifier without going through the zone widget. - * Used in hover mode to avoid instantiating the zone widget. - * - * Priority: user session choice > inlineChat.defaultModel setting > vendor default for location - */ - async #resolveModelId(location: ChatAgentLocation): Promise { - const userSelectedModel = InlineChatController.#userSelectedModel; - const defaultModelSetting = this.#configurationService.getValue(InlineChatConfigKeys.DefaultModel); - - // 1. Try user's explicitly chosen model from a previous inline chat - if (userSelectedModel) { - const match = this.#languageModelService.lookupLanguageModelByQualifiedName(userSelectedModel); - if (match) { - return match.identifier; - } - // Previously selected model is no longer available - InlineChatController.#userSelectedModel = undefined; - } - - // 2. Try inlineChat.defaultModel setting - if (defaultModelSetting) { - const match = this.#languageModelService.lookupLanguageModelByQualifiedName(defaultModelSetting); - if (match) { - return match.identifier; - } - this.#logService.warn(`inlineChat.defaultModel setting value '${defaultModelSetting}' did not match any available model. Falling back to vendor default.`); - } - - // 3. Fall back to vendor default for the given location - for (const id of this.#languageModelService.getLanguageModelIds()) { - const metadata = this.#languageModelService.lookupLanguageModel(id); - if (metadata?.isDefaultForLocation[location]) { - return id; - } - } - - return undefined; - } - - /** - * Builds location data for chat requests without going through the zone widget. - */ - #buildLocationData(): { location: ChatAgentLocation; locationData: IChatLocationData } { - assertType(this.#editor.hasModel()); - - const notebookEditor = this.#notebookEditorService.getNotebookForPossibleCell(this.#editor); - if (notebookEditor) { - const useNotebookAgent = this.#configurationService.getValue(InlineChatConfigKeys.notebookAgent); - if (useNotebookAgent) { - return { - location: ChatAgentLocation.Notebook, - locationData: { - type: ChatAgentLocation.Notebook, - sessionInputUri: this.#editor.getModel().uri, - } - }; - } - // Notebook cell but notebookAgent config is off: use Notebook location - // but with EditorInline-shaped locationData (matches zone widget behavior) - return { - location: ChatAgentLocation.Notebook, - locationData: { - type: ChatAgentLocation.EditorInline, - id: getEditorId(this.#editor, this.#editor.getModel()), - selection: this.#editor.getSelection(), - document: this.#editor.getModel().uri, - wholeRange: this.#editor.getSelection(), - } - }; - } - - return { - location: ChatAgentLocation.EditorInline, - locationData: { - type: ChatAgentLocation.EditorInline, - id: getEditorId(this.#editor, this.#editor.getModel()), - selection: this.#editor.getSelection(), - document: this.#editor.getModel().uri, - wholeRange: this.#editor.getSelection(), - } - }; - } - /** * Applies model defaults based on settings and tracks user model changes. * Prioritization: user session choice > inlineChat.defaultModel setting > vendor default diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatHistoryService.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatHistoryService.ts deleted file mode 100644 index e7813cc770ef3..0000000000000 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatHistoryService.ts +++ /dev/null @@ -1,97 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { HistoryNavigator2 } from '../../../../base/common/history.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; - -export const IInlineChatHistoryService = createDecorator('IInlineChatHistoryService'); - -export interface IInlineChatHistoryService { - readonly _serviceBrand: undefined; - - addToHistory(value: string): void; - previousValue(): string | undefined; - nextValue(): string | undefined; - isAtEnd(): boolean; - replaceLast(value: string): void; - resetCursor(): void; -} - -const _storageKey = 'inlineChat.history'; -const _capacity = 50; - -export class InlineChatHistoryService extends Disposable implements IInlineChatHistoryService { - declare readonly _serviceBrand: undefined; - - readonly #history: HistoryNavigator2; - readonly #storageService: IStorageService; - - constructor( - @IStorageService storageService: IStorageService, - ) { - super(); - - this.#storageService = storageService; - - const raw = this.#storageService.get(_storageKey, StorageScope.PROFILE); - let entries: string[] = ['']; - if (raw) { - try { - const parsed: string[] = JSON.parse(raw); - if (Array.isArray(parsed) && parsed.length > 0) { - entries = parsed; - // Ensure there's always an empty uncommitted entry at the end - if (entries[entries.length - 1] !== '') { - entries.push(''); - } - } - } catch { - // ignore invalid data - } - } - - this.#history = new HistoryNavigator2(entries, _capacity); - - this._store.add(this.#storageService.onWillSaveState(() => { - this.#saveToStorage(); - })); - } - - #saveToStorage(): void { - const values = [...this.#history].filter(v => v.length > 0); - if (values.length === 0) { - this.#storageService.remove(_storageKey, StorageScope.PROFILE); - } else { - this.#storageService.store(_storageKey, JSON.stringify(values), StorageScope.PROFILE, StorageTarget.USER); - } - } - - addToHistory(value: string): void { - this.#history.replaceLast(value); - this.#history.add(''); - } - - previousValue(): string | undefined { - return this.#history.previous(); - } - - nextValue(): string | undefined { - return this.#history.next(); - } - - isAtEnd(): boolean { - return this.#history.isAtEnd(); - } - - replaceLast(value: string): void { - this.#history.replaceLast(value); - } - - resetCursor(): void { - this.#history.resetCursor(); - } -} diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts deleted file mode 100644 index da9692cdf185d..0000000000000 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts +++ /dev/null @@ -1,693 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import './media/inlineChatOverlayWidget.css'; -import * as dom from '../../../../base/browser/dom.js'; -import { DEFAULT_FONT_FAMILY } from '../../../../base/browser/fonts.js'; -import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; -import { renderAsPlaintext, renderMarkdown } from '../../../../base/browser/markdownRenderer.js'; -import { ActionsOrientation } from '../../../../base/browser/ui/actionbar/actionbar.js'; -import { WorkbenchActionBar } from '../../../../platform/actions/browser/actionbar.js'; -import { BaseActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; -import { DomScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; -import { ActionRunner, IAction } from '../../../../base/common/actions.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { MarkdownString } from '../../../../base/common/htmlContent.js'; -import { KeyCode } from '../../../../base/common/keyCodes.js'; -import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, constObservable, derived, IObservable, observableFromEvent, observableFromEventOpts, observableValue } from '../../../../base/common/observable.js'; -import { ScrollbarVisibility } from '../../../../base/common/scrollable.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { URI } from '../../../../base/common/uri.js'; -import { IActiveCodeEditor, IOverlayWidgetPosition } from '../../../../editor/browser/editorBrowser.js'; -import { ObservableCodeEditor } from '../../../../editor/browser/observableCodeEditor.js'; -import { EditorOption } from '../../../../editor/common/config/editorOptions.js'; -import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js'; -import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; -import { IModelService } from '../../../../editor/common/services/model.js'; -import { localize } from '../../../../nls.js'; -import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; -import { getFlatActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; -import { IMenuService, MenuId } from '../../../../platform/actions/common/actions.js'; -import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { ChatEditingAcceptRejectActionViewItem } from '../../chat/browser/chatEditing/chatEditingEditorOverlay.js'; -import { CTX_INLINE_CHAT_INPUT_HAS_TEXT, CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED } from '../common/inlineChat.js'; -import { StickyScrollController } from '../../../../editor/contrib/stickyScroll/browser/stickyScrollController.js'; -import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { getSimpleEditorOptions } from '../../codeEditor/browser/simpleEditorOptions.js'; -import { PlaceholderTextContribution } from '../../../../editor/contrib/placeholderText/browser/placeholderTextContribution.js'; -import { IInlineChatSession2 } from './inlineChatSessionService.js'; -import { assertType } from '../../../../base/common/types.js'; -import { IInlineChatHistoryService } from './inlineChatHistoryService.js'; - -/** - * Overlay widget that displays a vertical action bar menu. - */ -export class InlineChatInputWidget extends Disposable { - - readonly #domNode: HTMLElement; - readonly #container: HTMLElement; - readonly #inputContainer: HTMLElement; - readonly #toolbarContainer: HTMLElement; - readonly #input: IActiveCodeEditor; - readonly #position = observableValue(this, null); - readonly position: IObservable = this.#position; - - readonly #showStore = this._store.add(new DisposableStore()); - readonly #stickyScrollHeight: IObservable; - readonly #layoutData: IObservable<{ totalWidth: number; toolbarWidth: number; height: number; editorPad: number }>; - #anchorLineNumber: number = 0; - #anchorLeft: number = 0; - #anchorAbove: boolean = false; - - readonly #editorObs: ObservableCodeEditor; - readonly #historyService: IInlineChatHistoryService; - - constructor( - editorObs: ObservableCodeEditor, - @IContextKeyService contextKeyService: IContextKeyService, - @IMenuService menuService: IMenuService, - @IInstantiationService instantiationService: IInstantiationService, - @IModelService modelService: IModelService, - @IConfigurationService configurationService: IConfigurationService, - @IInlineChatHistoryService historyService: IInlineChatHistoryService, - ) { - super(); - - this.#editorObs = editorObs; - this.#historyService = historyService; - - // Create container - this.#domNode = dom.$('.inline-chat-gutter-menu'); - - // Create inner container (background + focus border) - this.#container = dom.append(this.#domNode, dom.$('.inline-chat-gutter-container')); - - // Create input editor container - this.#inputContainer = dom.append(this.#container, dom.$('.input')); - - // Create toolbar container - this.#toolbarContainer = dom.append(this.#container, dom.$('.toolbar')); - - // Create vertical actions bar below the input container - const actionsContainer = dom.append(this.#domNode, dom.$('.inline-chat-gutter-actions')); - const actionBar = this._store.add(instantiationService.createInstance(WorkbenchActionBar, actionsContainer, { - orientation: ActionsOrientation.VERTICAL, - preventLoopNavigation: true, - telemetrySource: 'inlineChatInput.actionBar', - })); - const actionsMenu = this._store.add(menuService.createMenu(MenuId.ChatEditorInlineMenu, contextKeyService)); - const updateActions = () => { - const actions = getFlatActionBarActions(actionsMenu.getActions({ shouldForwardArgs: true })); - actionBar.clear(); - actionBar.push(actions); - dom.setVisibility(actions.length > 0, actionsContainer); - }; - this._store.add(actionsMenu.onDidChange(updateActions)); - updateActions(); - - // Create editor options - const options = getSimpleEditorOptions(configurationService); - options.wordWrap = 'off'; - options.wrappingStrategy = 'advanced'; - options.lineNumbers = 'off'; - options.glyphMargin = false; - options.lineDecorationsWidth = 0; - options.lineNumbersMinChars = 0; - options.folding = false; - options.minimap = { enabled: false }; - options.scrollbar = { vertical: 'hidden', horizontal: 'hidden', alwaysConsumeMouseWheel: true }; - options.renderLineHighlight = 'none'; - options.fontFamily = DEFAULT_FONT_FAMILY; - options.fontSize = 13; - options.lineHeight = 20; - options.cursorWidth = 1; - options.padding = { top: 2, bottom: 2 }; - - const codeEditorWidgetOptions: ICodeEditorWidgetOptions = { - isSimpleWidget: true, - contributions: EditorExtensionsRegistry.getSomeEditorContributions([ - PlaceholderTextContribution.ID, - ]) - }; - - this.#input = this._store.add(instantiationService.createInstance(CodeEditorWidget, this.#inputContainer, options, codeEditorWidgetOptions)) as IActiveCodeEditor; - - const model = this._store.add(modelService.createModel('', null, URI.parse(`gutter-input:${Date.now()}`), true)); - this.#input.setModel(model); - - // Create toolbar - const toolbar = this._store.add(instantiationService.createInstance(MenuWorkbenchToolBar, this.#toolbarContainer, MenuId.InlineChatInput, { - telemetrySource: 'inlineChatInput.toolbar', - hiddenItemStrategy: HiddenItemStrategy.NoHide, - toolbarOptions: { - primaryGroup: () => true, - }, - menuOptions: { shouldForwardArgs: true }, - })); - - // Initialize sticky scroll height observable - const stickyScrollController = StickyScrollController.get(this.#editorObs.editor); - this.#stickyScrollHeight = stickyScrollController ? observableFromEvent(stickyScrollController.onDidChangeStickyScrollHeight, () => stickyScrollController.stickyScrollWidgetHeight) : constObservable(0); - - // Track toolbar width changes - const toolbarWidth = observableValue(this, 0); - const resizeObserver = new dom.DisposableResizeObserver(() => { - toolbarWidth.set(dom.getTotalWidth(toolbar.getElement()), undefined); - }); - this._store.add(resizeObserver); - this._store.add(resizeObserver.observe(toolbar.getElement())); - - const contentWidth = observableFromEvent(this, this.#input.onDidChangeModelContent, () => this.#input.getContentWidth()); - const contentHeight = observableFromEvent(this, this.#input.onDidContentSizeChange, () => this.#input.getContentHeight()); - - this.#layoutData = derived(r => { - const editorPad = 6; - const totalWidth = contentWidth.read(r) + editorPad + toolbarWidth.read(r); - const minWidth = 220; - const maxWidth = 600; - const midWidth = Math.round(maxWidth / 1.618); - let clampedWidth: number; - if (this.#input.getOption(EditorOption.wordWrap) === 'on') { - clampedWidth = maxWidth; - } else if (totalWidth <= minWidth) { - clampedWidth = minWidth; - } else if (totalWidth <= midWidth) { - clampedWidth = midWidth; - } else { - clampedWidth = maxWidth; - } - - const lineHeight = this.#input.getOption(EditorOption.lineHeight); - const clampedHeight = Math.min(contentHeight.read(r), (3 * lineHeight)); - - if (totalWidth > clampedWidth) { - // enable word wrap - this.#input.updateOptions({ wordWrap: 'on', }); - } - - return { - editorPad, - toolbarWidth: toolbarWidth.read(r), - totalWidth: clampedWidth, - height: clampedHeight - }; - }); - - // Update container width and editor layout when width changes - this._store.add(autorun(r => { - const { editorPad, toolbarWidth, totalWidth, height } = this.#layoutData.read(r); - - const inputWidth = totalWidth - toolbarWidth - editorPad; - this.#container.style.width = `${totalWidth}px`; - this.#inputContainer.style.width = `${inputWidth}px`; - this.#input.layout({ width: inputWidth, height }); - if (this.#position.read(undefined) !== null) { - this.#updatePosition(); - } - })); - - // Toggle focus class on the container - this._store.add(this.#input.onDidFocusEditorText(() => this.#container.classList.add('focused'))); - this._store.add(this.#input.onDidBlurEditorText(() => this.#container.classList.remove('focused'))); - - // Toggle scroll decoration on the toolbar - this._store.add(this.#input.onDidScrollChange(e => { - this.#toolbarContainer.classList.toggle('fake-scroll-decoration', e.scrollTop > 0); - })); - - - // Track input text for context key and adjust width based on content - const inputHasText = CTX_INLINE_CHAT_INPUT_HAS_TEXT.bindTo(contextKeyService); - this._store.add(this.#input.onDidChangeModelContent(() => { - inputHasText.set(this.#input.getModel().getValue().trim().length > 0); - })); - this._store.add(toDisposable(() => inputHasText.reset())); - - // Track focus state - const inputWidgetFocused = CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED.bindTo(contextKeyService); - this._store.add(this.#input.onDidFocusEditorText(() => inputWidgetFocused.set(true))); - this._store.add(this.#input.onDidBlurEditorText(() => inputWidgetFocused.set(false))); - this._store.add(toDisposable(() => inputWidgetFocused.reset())); - - // Handle key events: ArrowUp/ArrowDown for history navigation and action bar focus - this._store.add(this.#input.onKeyDown(e => { - if (e.keyCode === KeyCode.UpArrow) { - const position = this.#input.getPosition(); - if (position && position.lineNumber === 1) { - this.#showPreviousHistoryValue(); - e.preventDefault(); - e.stopPropagation(); - } - } else if (e.keyCode === KeyCode.DownArrow) { - const model = this.#input.getModel(); - const position = this.#input.getPosition(); - if (position && position.lineNumber === model.getLineCount()) { - if (!this.#historyService.isAtEnd()) { - this.#showNextHistoryValue(); - e.preventDefault(); - e.stopPropagation(); - } else if (!actionBar.isEmpty()) { - e.preventDefault(); - e.stopPropagation(); - actionBar.focus(0); - } - } - } - })); - - // ArrowUp on first action bar item moves focus back to input editor - // Escape on action bar hides the widget - this._store.add(dom.addDisposableListener(actionBar.domNode, 'keydown', (e: KeyboardEvent) => { - const event = new StandardKeyboardEvent(e); - if (event.keyCode === KeyCode.Escape) { - event.preventDefault(); - event.stopPropagation(); - this.hide(); - } else if (event.keyCode === KeyCode.UpArrow) { - const firstItem = actionBar.viewItems[0] as BaseActionViewItem | undefined; - if (firstItem?.element && dom.isAncestorOfActiveElement(firstItem.element)) { - event.preventDefault(); - event.stopPropagation(); - this.#input.focus(); - } - } - }, true)); - - // Track focus - hide when focus leaves - const focusTracker = this._store.add(dom.trackFocus(this.#domNode)); - this._store.add(focusTracker.onDidBlur(() => this.hide())); - } - - get value(): string { - return this.#input.getModel().getValue().trim(); - } - - addToHistory(value: string): void { - this.#historyService.addToHistory(value); - } - - #showPreviousHistoryValue(): void { - if (this.#historyService.isAtEnd()) { - this.#historyService.replaceLast(this.#input.getModel().getValue()); - } - const value = this.#historyService.previousValue(); - if (value !== undefined) { - this.#input.getModel().setValue(value); - } - } - - #showNextHistoryValue(): void { - if (this.#historyService.isAtEnd()) { - return; - } - const value = this.#historyService.nextValue(); - if (value !== undefined) { - this.#input.getModel().setValue(value); - } - } - - /** - * Show the widget at the specified line. - * @param lineNumber The line number to anchor the widget to - * @param left Left offset relative to editor - * @param anchorAbove Whether to anchor above the position (widget grows upward) - */ - show(lineNumber: number, left: number, anchorAbove: boolean, placeholder: string, value?: string): void { - this.#showStore.clear(); - - // Reset history cursor to the end (current uncommitted text) - this.#historyService.resetCursor(); - - // Clear input state - this.#input.updateOptions({ wordWrap: 'off', placeholder }); - this.#input.getModel().setValue(value ?? ''); - - // Store anchor info for scroll updates - this.#anchorLineNumber = lineNumber; - this.#anchorLeft = left; - this.#anchorAbove = anchorAbove; - - // Set initial position - this.#updatePosition(); - - // Create overlay widget via observable pattern - this.#showStore.add(this.#editorObs.createOverlayWidget({ - domNode: this.#domNode, - position: this.#position, - minContentWidthInPx: constObservable(0), - allowEditorOverflow: true, - })); - - // Re-adjust position after render to account for widget dimensions (offsetWidth/offsetHeight - // are only available after the widget is added to the DOM) - this.#updatePosition(); - - // Update position on scroll, hide if anchor line is out of view (only when input is empty) - this.#showStore.add(this.#editorObs.editor.onDidScrollChange(() => { - const visibleRanges = this.#editorObs.editor.getVisibleRanges(); - const isLineVisible = visibleRanges.some(range => - this.#anchorLineNumber >= range.startLineNumber && this.#anchorLineNumber <= range.endLineNumber - ); - const hasContent = !!this.#input.getModel().getValue(); - if (!isLineVisible && !hasContent) { - this.hide(); - } else { - this.#updatePosition(); - } - })); - - // Update position when the editor resizes (e.g. sidebar toggle, window resize) - this.#showStore.add(this.#editorObs.editor.onDidLayoutChange(() => { - this.#updatePosition(); - })); - - // Focus the input editor - setTimeout(() => { - this.#input.focus(); - if (value) { - this.#input.setSelection(this.#input.getModel().getFullModelRange()); - } - }, 0); - } - - #updatePosition(): void { - const editor = this.#editorObs.editor; - const lineHeight = editor.getOption(EditorOption.lineHeight); - const top = editor.getTopForLineNumber(this.#anchorLineNumber) - editor.getScrollTop(); - let adjustedTop = top; - - if (this.#anchorAbove) { - const widgetHeight = this.#domNode.offsetHeight; - adjustedTop = top - widgetHeight; - } else { - adjustedTop = top + lineHeight; - } - - // Clamp to viewport bounds when anchor line is out of view - const stickyScrollHeight = this.#stickyScrollHeight.get(); - const layoutInfo = editor.getLayoutInfo(); - const widgetHeight = this.#domNode.offsetHeight; - const widgetWidth = this.#domNode.offsetWidth; - const minTop = stickyScrollHeight; - const maxTop = layoutInfo.height - widgetHeight; - const padding = 8; - const maxLeft = layoutInfo.width - layoutInfo.verticalScrollbarWidth - layoutInfo.minimap.minimapWidth - widgetWidth - padding; - - const clampedTop = Math.max(minTop, Math.min(adjustedTop, maxTop)); - const clampedLeft = Math.max(0, Math.min(this.#anchorLeft, maxLeft)); - const isClamped = clampedTop !== adjustedTop || clampedLeft !== this.#anchorLeft; - this.#domNode.classList.toggle('clamped', isClamped); - - this.#position.set({ - preference: { top: clampedTop, left: clampedLeft }, - stackOrdinal: 10000, - }, undefined); - } - - hide(): void { - const editorDomNode = this.#editorObs.editor.getDomNode(); - if (editorDomNode && dom.isAncestorOfActiveElement(editorDomNode)) { - this.#editorObs.editor.focus(); - } - this.#position.set(null, undefined); - this.#input.getModel().setValue(''); - this.#showStore.clear(); - } -} - -/** - * Overlay widget that displays progress messages during inline chat requests. - */ -export class InlineChatSessionOverlayWidget extends Disposable { - - readonly #domNode: HTMLElement = document.createElement('div'); - readonly #container: HTMLElement; - readonly #markdownContainer: HTMLElement; - readonly #markdownMessage: HTMLElement; - readonly #markdownScrollable: DomScrollableElement; - readonly #contentRow: HTMLElement; - readonly #statusNode: HTMLElement; - readonly #icon: HTMLElement; - readonly #message: HTMLElement; - readonly #toolbarNode: HTMLElement; - - readonly #showStore = this._store.add(new DisposableStore()); - readonly #position = observableValue(this, null); - readonly #minContentWidthInPx = constObservable(0); - - readonly #stickyScrollHeight: IObservable; - - readonly #editorObs: ObservableCodeEditor; - readonly #instaService: IInstantiationService; - readonly #keybindingService: IKeybindingService; - - constructor( - editorObs: ObservableCodeEditor, - @IInstantiationService instaService: IInstantiationService, - @IKeybindingService keybindingService: IKeybindingService, - ) { - super(); - - this.#editorObs = editorObs; - this.#instaService = instaService; - this.#keybindingService = keybindingService; - - this.#domNode.classList.add('inline-chat-session-overlay-widget'); - - this.#container = document.createElement('div'); - this.#domNode.appendChild(this.#container); - this.#container.classList.add('inline-chat-session-overlay-container'); - - this.#markdownContainer = document.createElement('div'); - this.#markdownContainer.classList.add('markdown-scroll-container'); - - this.#markdownMessage = document.createElement('div'); - this.#markdownMessage.classList.add('markdown-message'); - this.#markdownContainer.appendChild(this.#markdownMessage); - this.#markdownScrollable = this._store.add(new DomScrollableElement(this.#markdownContainer, { - consumeMouseWheelIfScrollbarIsNeeded: true, - horizontal: ScrollbarVisibility.Hidden, - vertical: ScrollbarVisibility.Auto, - })); - this.#container.appendChild(this.#markdownScrollable.getDomNode()); - - this.#contentRow = document.createElement('div'); - this.#contentRow.classList.add('content-row'); - this.#container.appendChild(this.#contentRow); - - // Create status node with icon and message - this.#statusNode = document.createElement('div'); - this.#statusNode.classList.add('status'); - this.#icon = dom.append(this.#statusNode, dom.$('span')); - this.#message = dom.append(this.#statusNode, dom.$('span.message')); - this.#contentRow.appendChild(this.#statusNode); - - // Create toolbar node - this.#toolbarNode = document.createElement('div'); - this.#toolbarNode.classList.add('toolbar'); - - // Initialize sticky scroll height observable - const stickyScrollController = StickyScrollController.get(this.#editorObs.editor); - this.#stickyScrollHeight = stickyScrollController ? observableFromEvent(stickyScrollController.onDidChangeStickyScrollHeight, () => stickyScrollController.stickyScrollWidgetHeight) : constObservable(0); - } - - show(session: IInlineChatSession2): void { - assertType(this.#editorObs.editor.hasModel()); - this.#showStore.clear(); - - // Derived entry observable for this session - const entry = derived(r => session.editingSession.readEntry(session.uri, r)); - - // Set up status message and icon observable - const requestMessage = derived(r => { - const chatModel = session?.chatModel; - if (!session || !chatModel) { - return undefined; - } - - const terminationState = session.terminationState.read(r); - if (terminationState) { - return { - markdown: terminationState, - icon: Codicon.info - }; - } - - const response = chatModel.lastRequestObs.read(r)?.response; - if (!response) { - return { message: localize('working', "Working..."), icon: ThemeIcon.modify(Codicon.loading, 'spin') }; - } - - if (response.isComplete) { - // Check for errors first - const result = response.result; - if (result?.errorDetails) { - return { - message: localize('error', "Sorry, your request failed"), - icon: Codicon.error - }; - } - - const changes = entry.read(r)?.changesCount.read(r) ?? 0; - return { - message: changes === 0 - ? localize('done', "Done") - : changes === 1 - ? localize('done1', "Done, 1 change") - : localize('doneN', "Done, {0} changes", changes), - icon: Codicon.check - }; - } - - const pendingConfirmation = response.isPendingConfirmation.read(r); - if (pendingConfirmation) { - return { - message: localize('needsApproval', "Sorry, but an unexpected error happened"), - icon: Codicon.error - }; - } - - const lastPart = observableFromEventOpts({ equalsFn: () => false }, response.onDidChange, () => response.response.value) - .read(r) - .filter(part => part.kind === 'progressMessage' || part.kind === 'toolInvocation') - .at(-1); - - if (lastPart?.kind === 'toolInvocation') { - return { message: lastPart.invocationMessage, icon: ThemeIcon.modify(Codicon.loading, 'spin') }; - } else if (lastPart?.kind === 'progressMessage') { - return { message: lastPart.content, icon: ThemeIcon.modify(Codicon.loading, 'spin') }; - } else { - return { message: localize('working', "Working..."), icon: ThemeIcon.modify(Codicon.loading, 'spin') }; - } - }); - - const markdownStore = this.#showStore.add(new DisposableStore()); - - this.#showStore.add(autorun(r => { - const value = requestMessage.read(r); - if (value) { - if (value.message && value.icon) { - this.#message.innerText = renderAsPlaintext(value.message); - this.#icon.className = ''; - this.#icon.classList.add(...ThemeIcon.asClassNameArray(value.icon)); - this.#statusNode.classList.remove('hidden'); - this.#contentRow.classList.remove('status-hidden'); - } else { - this.#message.innerText = ''; - this.#icon.className = ''; - this.#statusNode.classList.add('hidden'); - this.#contentRow.classList.add('status-hidden'); - } - markdownStore.clear(); - this.#markdownMessage.replaceChildren(); - if (value.markdown) { - this.#markdownScrollable.getDomNode().classList.remove('hidden'); - const markdown = typeof value.markdown === 'string' ? new MarkdownString(value.markdown) : value.markdown; - const rendered = markdownStore.add(renderMarkdown(markdown)); - this.#markdownMessage.appendChild(rendered.element); - this.#markdownScrollable.scanDomNode(); - } else { - this.#markdownScrollable.getDomNode().classList.add('hidden'); - } - } else { - this.#message.innerText = ''; - this.#icon.className = ''; - this.#statusNode.classList.add('hidden'); - this.#contentRow.classList.add('status-hidden'); - markdownStore.clear(); - this.#markdownMessage.replaceChildren(); - this.#markdownScrollable.getDomNode().classList.add('hidden'); - } - })); - - // Add toolbar - this.#contentRow.appendChild(this.#toolbarNode); - this.#showStore.add(toDisposable(() => this.#toolbarNode.remove())); - - const that = this; - - // Focus the owning editor before running any toolbar action so that - // EditorAction2-based actions resolve the correct editor instance - // even when the user has clicked into a different editor. - const actionRunner = this.#showStore.add(new class extends ActionRunner { - protected override async runAction(action: IAction, context?: unknown): Promise { - that.#editorObs.editor.focus(); - return super.runAction(action, context); - } - }); - - this.#showStore.add(this.#instaService.createInstance(MenuWorkbenchToolBar, this.#toolbarNode, MenuId.ChatEditorInlineExecute, { - telemetrySource: 'inlineChatProgress.overlayToolbar', - hiddenItemStrategy: HiddenItemStrategy.Ignore, - actionRunner, - toolbarOptions: { - primaryGroup: () => true, - useSeparatorsInPrimaryActions: true - }, - menuOptions: { renderShortTitle: true }, - actionViewItemProvider: (action, options) => { - const primaryActions = ['inlineChat2.cancel', 'inlineChat2.keep', 'inlineChat2.rephrase']; - const labeledActions = primaryActions.concat(['inlineChat2.undo']); - - if (!labeledActions.includes(action.id)) { - return undefined; // use default action view item with label - } - - return new ChatEditingAcceptRejectActionViewItem(action, { ...options, keybinding: undefined }, entry, undefined, that.#keybindingService, primaryActions); - } - })); - - // Position in top right of editor, below sticky scroll - const lineHeight = this.#editorObs.getOption(EditorOption.lineHeight); - - // Track widget width changes - const widgetWidth = observableValue(this, 0); - const resizeObserver = new dom.DisposableResizeObserver(() => { - widgetWidth.set(this.#domNode.offsetWidth, undefined); - }); - this.#showStore.add(resizeObserver); - this.#showStore.add(resizeObserver.observe(this.#domNode)); - - this.#showStore.add(autorun(r => { - const layoutInfo = this.#editorObs.layoutInfo.read(r); - const stickyScrollHeight = this.#stickyScrollHeight.read(r); - const width = widgetWidth.read(r); - const padding = Math.round(lineHeight.read(r) * 2 / 3); - - // Cap max-width to the editor viewport (content area) - const maxWidth = Math.min(400, layoutInfo.contentWidth - 2 * padding); - const maxHeight = Math.min(150, Math.floor(layoutInfo.height / 3)); - this.#domNode.style.maxWidth = `${maxWidth}px`; - this.#markdownScrollable.getDomNode().style.maxHeight = `${maxHeight}px`; - this.#markdownContainer.style.maxHeight = `${maxHeight}px`; - this.#markdownScrollable.scanDomNode(); - - // Position: top right, below sticky scroll with padding, left of minimap and scrollbar - const top = stickyScrollHeight + padding; - const left = layoutInfo.width - width - layoutInfo.verticalScrollbarWidth - layoutInfo.minimap.minimapWidth - padding; - - this.#position.set({ - preference: { top, left }, - stackOrdinal: 10000, - }, undefined); - })); - - // Create overlay widget - this.#showStore.add(this.#editorObs.createOverlayWidget({ - domNode: this.#domNode, - position: this.#position, - minContentWidthInPx: this.#minContentWidthInPx, - allowEditorOverflow: false, - })); - } - - hide(): void { - this.#position.set(null, undefined); - this.#showStore.clear(); - } -} diff --git a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatOverlayWidget.css b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatOverlayWidget.css deleted file mode 100644 index 68424c910247a..0000000000000 --- a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatOverlayWidget.css +++ /dev/null @@ -1,231 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - - -/* Gutter menu overlay widget */ -.inline-chat-gutter-menu { - background: var(--vscode-panel-background); - border: 1px solid var(--vscode-menu-border, var(--vscode-widget-border)); - border-radius: var(--vscode-cornerRadius-large); - box-shadow: var(--vscode-shadow-lg); - z-index: 100; -} - -.inline-chat-gutter-menu .input { - padding: 0 3px; -} - -.inline-chat-gutter-menu .monaco-action-bar.vertical .action-item { - display: flex; - justify-content: space-between; - border-radius: 3px; - margin: 0 4px; -} - -.inline-chat-gutter-menu .inline-chat-gutter-actions { - padding-bottom: 2px; -} - -.inline-chat-gutter-menu .monaco-action-bar.vertical .action-item .action-label { - font-size: 13px; - width: 100%; -} - -.inline-chat-gutter-menu .monaco-action-bar.vertical .action-item:not(.disabled):hover, -.inline-chat-gutter-menu .monaco-action-bar.vertical .action-item:not(.disabled):focus-within { - background-color: var(--vscode-list-activeSelectionBackground); - color: var(--vscode-list-activeSelectionForeground); - outline: 1px solid var(--vscode-menu-selectionBorder, transparent); - outline-offset: -1px; -} - -.inline-chat-gutter-menu .monaco-action-bar.vertical .action-item:not(.disabled):hover .action-label, -.inline-chat-gutter-menu .monaco-action-bar.vertical .action-item:not(.disabled):focus-within .action-label { - color: var(--vscode-list-activeSelectionForeground); - outline: 1px solid var(--vscode-menu-selectionBorder, transparent); - outline-offset: -1px; -} - - -.inline-chat-gutter-menu.clamped { - transition: top 100ms; -} - -.inline-chat-gutter-menu .inline-chat-gutter-container { - box-sizing: border-box; - display: flex; - align-items: center; - margin: 6px 4px 4px 4px; - background-color: var(--vscode-input-background); - border: 1px solid var(--vscode-input-border, transparent); - border-radius: 4px; - overflow: hidden; -} - -.inline-chat-gutter-menu .inline-chat-gutter-container.focused { - border-color: var(--vscode-focusBorder); -} - -.inline-chat-gutter-menu .inline-chat-gutter-container > .input { - flex: 1; - min-width: 0; -} - -.inline-chat-gutter-menu .inline-chat-gutter-container > .input .monaco-editor-background { - background-color: var(--vscode-input-background); -} - -.inline-chat-gutter-menu .inline-chat-gutter-container > .toolbar { - display: flex; - align-items: center; - align-self: stretch; - padding: 0 4px; -} - -.inline-chat-gutter-menu .inline-chat-gutter-container > .toolbar.fake-scroll-decoration { - box-shadow: var(--vscode-scrollbar-shadow) 0 6px 6px -6px inset; -} - -.inline-chat-gutter-menu .inline-chat-gutter-container > .toolbar .monaco-action-bar .actions-container { - gap: 2px; -} - -.inline-chat-session-overlay-widget { - z-index: 1; - transition: top 100ms; -} - -.inline-chat-session-overlay-container { - padding: 4px; - color: var(--vscode-foreground); - background-color: var(--vscode-editorWidget-background); - border-radius: 6px; - border: 1px solid var(--vscode-contrastBorder); - display: flex; - flex-direction: column; - align-items: stretch; - justify-content: center; - gap: 8px; - z-index: 10; - box-shadow: var(--vscode-shadow-lg); - overflow: hidden; -} - -.inline-chat-session-overlay-container .markdown-message { - padding: 5px; - font-size: 12px; - line-height: 1.45; -} - -.inline-chat-session-overlay-container > .monaco-scrollable-element { - width: 100%; -} - -.inline-chat-session-overlay-container > .monaco-scrollable-element.hidden { - display: none; -} - -.inline-chat-session-overlay-container .markdown-scroll-container { - width: 100%; -} - -.inline-chat-session-overlay-container .markdown-message DIV P { - margin: 0; -} - -.inline-chat-session-overlay-container .markdown-message DIV P code { - font-family: var(--monaco-monospace-font); - font-size: var(--vscode-chat-font-size-body-xs); - color: var(--vscode-textPreformat-foreground); - background-color: var(--vscode-textPreformat-background); - padding: 1px 3px; - border-radius: 4px; - border: 1px solid var(--vscode-textPreformat-border); - white-space: pre-wrap; -} - - -.inline-chat-session-overlay-container .content-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 4px; - min-width: 0; -} - -.inline-chat-session-overlay-container .content-row.status-hidden { - justify-content: flex-end; -} - -.inline-chat-session-overlay-container .status { - align-items: center; - display: inline-flex; - flex: 1; - padding: 5px 0 5px 5px; - font-size: 12px; - overflow: hidden; - gap: 6px; -} - -.inline-chat-session-overlay-container .status.hidden { - display: none; -} - -.inline-chat-session-overlay-container .status .message { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.inline-chat-session-overlay-container .status .message:not(:empty) { - padding-right: 2em; -} - -.inline-chat-session-overlay-container .status .codicon { - color: var(--vscode-foreground); -} - -.inline-chat-session-overlay-container .action-item > .action-label { - padding: 4px 6px; - font-size: 11px; - line-height: 14px; - border-radius: 4px; -} - -.inline-chat-session-overlay-container .monaco-action-bar .actions-container { - gap: 4px; -} - -.inline-chat-session-overlay-container .action-item.primary > .action-label { - background-color: var(--vscode-button-background); - color: var(--vscode-button-foreground); -} - -.monaco-workbench .inline-chat-session-overlay-container .monaco-action-bar .action-item.primary > .action-label:hover { - background-color: var(--vscode-button-hoverBackground); -} - -.inline-chat-session-overlay-container .action-item > .action-label.codicon:not(.separator) { - color: var(--vscode-foreground); - width: 22px; - height: 22px; - padding: 0; - font-size: 16px; - line-height: 22px; - display: flex; - align-items: center; - justify-content: center; -} - -.inline-chat-session-overlay-container .monaco-action-bar .action-item.disabled { - - > .action-label.codicon::before, - > .action-label.codicon, - > .action-label, - > .action-label:hover { - color: var(--vscode-button-separator); - opacity: 1; - } -} diff --git a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts index d71d63718fdf3..9a05e4cec1097 100644 --- a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts +++ b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts @@ -20,7 +20,6 @@ export const enum InlineChatConfigKeys { notebookAgent = 'inlineChat.notebookAgent', DefaultModel = 'inlineChat.defaultModel', Affordance = 'inlineChat.affordance', - RenderMode = 'inlineChat.renderMode', FixDiagnostics = 'inlineChat.fixDiagnostics', AskInChat = 'inlineChat.askInChat', } @@ -65,20 +64,6 @@ Registry.as(Extensions.Configuration).registerConfigurat }, tags: ['experimental'] }, - [InlineChatConfigKeys.RenderMode]: { - description: localize('renderMode', "Controls how inline chat is rendered."), - default: 'zone', - type: 'string', - enum: ['zone', 'hover'], - enumDescriptions: [ - localize('renderMode.zone', "Render inline chat as a zone widget below the current line."), - localize('renderMode.hover', "Render inline chat as a hover overlay."), - ], - experiment: { - mode: 'auto' - }, - tags: ['experimental'], - }, [InlineChatConfigKeys.FixDiagnostics]: { description: localize('fixDiagnostics', "Controls whether the Fix action is shown for diagnostics in the editor."), default: true, @@ -117,8 +102,6 @@ export const CTX_INLINE_CHAT_FOCUSED = new RawContextKey('inlineChatFoc export const CTX_INLINE_CHAT_EDITING = new RawContextKey('inlineChatEditing', true, localize('inlineChatEditing', "Whether the user is currently editing or generating code in the inline chat")); export const CTX_INLINE_CHAT_RESPONSE_FOCUSED = new RawContextKey('inlineChatResponseFocused', false, localize('inlineChatResponseFocused', "Whether the interactive widget's response is focused")); export const CTX_INLINE_CHAT_EMPTY = new RawContextKey('inlineChatEmpty', false, localize('inlineChatEmpty', "Whether the interactive editor input is empty")); -export const CTX_INLINE_CHAT_INPUT_HAS_TEXT = new RawContextKey('inlineChatInputHasText', false, localize('inlineChatInputHasText', "Whether the inline chat input widget has text")); -export const CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED = new RawContextKey('inlineChatInputWidgetFocused', false, localize('inlineChatInputWidgetFocused', "Whether the inline chat input widget editor is focused")); export const CTX_INLINE_CHAT_INNER_CURSOR_FIRST = new RawContextKey('inlineChatInnerCursorFirst', false, localize('inlineChatInnerCursorFirst', "Whether the cursor of the iteractive editor input is on the first line")); export const CTX_INLINE_CHAT_INNER_CURSOR_LAST = new RawContextKey('inlineChatInnerCursorLast', false, localize('inlineChatInnerCursorLast', "Whether the cursor of the iteractive editor input is on the last line")); export const CTX_INLINE_CHAT_OUTER_CURSOR_POSITION = new RawContextKey<'above' | 'below' | ''>('inlineChatOuterCursorPosition', '', localize('inlineChatOuterCursorPosition', "Whether the cursor of the outer editor is above or below the interactive editor input")); @@ -128,7 +111,6 @@ export const CTX_INLINE_CHAT_CHANGE_SHOWS_DIFF = new RawContextKey('inl export const CTX_INLINE_CHAT_REQUEST_IN_PROGRESS = new RawContextKey('inlineChatRequestInProgress', false, localize('inlineChatRequestInProgress', "Whether an inline chat request is currently in progress")); export const CTX_INLINE_CHAT_RESPONSE_TYPE = new RawContextKey('inlineChatResponseType', InlineChatResponseType.None, localize('inlineChatResponseTypes', "What type was the responses have been receieved, nothing yet, just messages, or messaged and local edits")); export const CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT = new RawContextKey('inlineChatFileBelongsToChat', false, localize('inlineChatFileBelongsToChat', "Whether the current file belongs to a chat editing session")); -export const CTX_INLINE_CHAT_PENDING_CONFIRMATION = new RawContextKey('inlineChatPendingConfirmation', false, localize('inlineChatPendingConfirmation', "Whether an inline chat request is pending user confirmation")); export const CTX_INLINE_CHAT_TERMINATED = new RawContextKey('inlineChatTerminated', false, localize('inlineChatTerminated', "Whether the current inline chat session is terminated")); export const CTX_INLINE_CHAT_AFFORDANCE_VISIBLE = new RawContextKey('inlineChatAffordanceVisible', false, localize('inlineChatAffordanceVisible', "Whether an inline chat affordance widget is visible")); @@ -141,7 +123,6 @@ export const CTX_INLINE_CHAT_V2_ENABLED = ContextKeyExpr.or( ContextKeyExpr.and(NOTEBOOK_IS_ACTIVE_EDITOR, CTX_INLINE_CHAT_HAS_NOTEBOOK_AGENT) ); -export const CTX_HOVER_MODE = ContextKeyExpr.equals('config.inlineChat.renderMode', 'hover'); export const CTX_FIX_DIAGNOSTICS_ENABLED = ContextKeyExpr.equals('config.inlineChat.fixDiagnostics', true); export const CTX_ASK_IN_CHAT_ENABLED = ContextKeyExpr.equals('config.inlineChat.askInChat', true); diff --git a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatAffordance.test.ts b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatAffordance.test.ts index 85719bf152cf6..bf3aa28d357da 100644 --- a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatAffordance.test.ts +++ b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatAffordance.test.ts @@ -5,7 +5,6 @@ import assert from 'assert'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; -import { observableValue } from '../../../../../base/common/observable.js'; import { Selection } from '../../../../../editor/common/core/selection.js'; import { CursorChangeReason } from '../../../../../editor/common/cursorEvents.js'; import { CursorState } from '../../../../../editor/common/cursorCommon.js'; @@ -16,7 +15,7 @@ import { ITelemetryService } from '../../../../../platform/telemetry/common/tele import { IInlineChatSessionService } from '../../browser/inlineChatSessionService.js'; import { Event } from '../../../../../base/common/event.js'; import { InlineChatAffordance } from '../../browser/inlineChatAffordance.js'; -import { InlineChatInputWidget } from '../../browser/inlineChatOverlayWidget.js'; + import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { timeout } from '../../../../../base/common/async.js'; @@ -27,15 +26,6 @@ import { workbenchInstantiationService } from '../../../../test/browser/workbenc import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { mock } from '../../../../../base/test/common/mock.js'; -function createMockInputWidget(): InlineChatInputWidget { - return new class extends mock() { - override readonly position = observableValue('test.position', null); - override show() { } - override hide() { } - override dispose() { } - }; -} - suite('InlineChatAffordance - Telemetry', () => { const store = new DisposableStore(); @@ -88,7 +78,7 @@ suite('InlineChatAffordance - Telemetry', () => { } test('shown event includes mode "editor"', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - store.add(instantiationService.createInstance(InlineChatAffordance, editor, createMockInputWidget())); + store.add(instantiationService.createInstance(InlineChatAffordance, editor)); setExplicitSelection(new Selection(1, 1, 1, 6)); await timeout(600); @@ -106,7 +96,7 @@ suite('InlineChatAffordance - Telemetry', () => { override affectsConfiguration(key: string) { return key === InlineChatConfigKeys.Affordance; } }); - store.add(instantiationService.createInstance(InlineChatAffordance, editor, createMockInputWidget())); + store.add(instantiationService.createInstance(InlineChatAffordance, editor)); setExplicitSelection(new Selection(1, 1, 1, 6)); await timeout(600); @@ -117,7 +107,7 @@ suite('InlineChatAffordance - Telemetry', () => { test('shown event does NOT fire for whitespace-only selection', () => runWithFakedTimers({ useFakeTimers: true }, async () => { model.setValue(' \nhello'); - store.add(instantiationService.createInstance(InlineChatAffordance, editor, createMockInputWidget())); + store.add(instantiationService.createInstance(InlineChatAffordance, editor)); setExplicitSelection(new Selection(1, 1, 1, 4)); await timeout(600); @@ -126,7 +116,7 @@ suite('InlineChatAffordance - Telemetry', () => { })); test('shown event does NOT fire for empty selection', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - store.add(instantiationService.createInstance(InlineChatAffordance, editor, createMockInputWidget())); + store.add(instantiationService.createInstance(InlineChatAffordance, editor)); setExplicitSelection(new Selection(1, 1, 1, 1)); await timeout(600); @@ -135,7 +125,7 @@ suite('InlineChatAffordance - Telemetry', () => { })); test('each selection gets a unique affordanceId', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - store.add(instantiationService.createInstance(InlineChatAffordance, editor, createMockInputWidget())); + store.add(instantiationService.createInstance(InlineChatAffordance, editor)); setExplicitSelection(new Selection(1, 1, 1, 6)); await timeout(600); diff --git a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts deleted file mode 100644 index 6357c2e6769ce..0000000000000 --- a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts +++ /dev/null @@ -1,336 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { timeout } from '../../../../../base/common/async.js'; -import { Emitter, Event } from '../../../../../base/common/event.js'; -import { DisposableStore } from '../../../../../base/common/lifecycle.js'; -import { observableValue } from '../../../../../base/common/observable.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { Selection } from '../../../../../editor/common/core/selection.js'; -import { ITextModel } from '../../../../../editor/common/model.js'; -import { createTextModel } from '../../../../../editor/test/common/testTextModel.js'; -import { ITestCodeEditor, instantiateTestCodeEditor } from '../../../../../editor/test/browser/testCodeEditor.js'; -import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; -import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; -import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { mock } from '../../../../../base/test/common/mock.js'; -import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; -import { InlineChatConfigKeys } from '../../common/inlineChat.js'; -import { IChatSendRequestOptions, IChatService } from '../../../chat/common/chatService/chatService.js'; -import { IInlineChatSession2, IInlineChatSessionService } from '../../browser/inlineChatSessionService.js'; -import { InlineChatController } from '../../browser/inlineChatController.js'; -import { ChatAgentLocation, ChatModeKind } from '../../../chat/common/constants.js'; -import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../chat/common/languageModels.js'; -import { IChatAgentData } from '../../../chat/common/participants/chatAgents.js'; -import { IChatModel, IChatResponseModel } from '../../../chat/common/model/chatModel.js'; -import { Range } from '../../../../../editor/common/core/range.js'; -import { IChatEditingService, IChatEditingSession, IModifiedFileEntry } from '../../../chat/common/editing/chatEditingService.js'; -import { Position } from '../../../../../editor/common/core/position.js'; -import { CursorChangeReason } from '../../../../../editor/common/cursorEvents.js'; -import { CursorState } from '../../../../../editor/common/cursorCommon.js'; -import { IUserInteractionService, MockUserInteractionService } from '../../../../../platform/userInteraction/browser/userInteractionService.js'; -import { INotebookEditorService } from '../../../notebook/browser/services/notebookEditorService.js'; -import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; -import { IMarkerDecorationsService } from '../../../../../editor/common/services/markerDecorations.js'; -import { IMarker, MarkerSeverity } from '../../../../../platform/markers/common/markers.js'; - -suite('InlineChatController - Request Parity', () => { - - const store = new DisposableStore(); - let editor: ITestCodeEditor; - let model: ITextModel; - let instantiationService: TestInstantiationService; - let configurationService: TestConfigurationService; - - /** Captured sendRequest calls: [sessionResource, message, options] */ - let sendRequestCalls: { sessionResource: URI; message: string; options?: IChatSendRequestOptions }[]; - /** Emitter to signal session dispose */ - let sessionDisposedEmitter: Emitter; - - const testModelId = 'test-model-id'; - const testModelQualifiedName = 'Test Model (TestVendor)'; - const testSessionResource = URI.parse('chat-session:test-session'); - - setup(() => { - sendRequestCalls = []; - sessionDisposedEmitter = store.add(new Emitter()); - - instantiationService = workbenchInstantiationService({ - configurationService: () => new TestConfigurationService({ - [InlineChatConfigKeys.RenderMode]: 'hover', - }), - }, store); - - configurationService = instantiationService.get(IConfigurationService) as TestConfigurationService; - - // Mock IUserInteractionService — needed for InlineChatInputWidget's internal code editor - instantiationService.stub(IUserInteractionService, new MockUserInteractionService()); - - // Mock INotebookEditorService - instantiationService.stub(INotebookEditorService, new class extends mock() { - override getNotebookForPossibleCell() { return undefined; } - }); - - // Mock IChatService — capture sendRequest calls - instantiationService.stub(IChatService, new class extends mock() { - override async sendRequest(sessionResource: URI, message: string, options?: IChatSendRequestOptions) { - sendRequestCalls.push({ sessionResource, message, options }); - return { kind: 'sent' as const, data: { agent: {} as Partial as IChatAgentData, responseCreatedPromise: Promise.resolve({} as Partial as IChatResponseModel), responseCompletePromise: Promise.resolve() } }; - } - override async cancelCurrentRequestForSession() { } - }); - - // Mock ILanguageModelsService - const testMetadata: ILanguageModelChatMetadata = { - vendor: 'TestVendor', - name: 'Test Model', - family: 'test', - version: '1', - id: testModelId, - maxInputTokens: 1000, - maxOutputTokens: 1000, - auth: undefined, - capabilities: {}, - isDefaultForLocation: { [ChatAgentLocation.EditorInline]: true }, - targetEntitlements: [], - } as Partial as ILanguageModelChatMetadata; - - instantiationService.stub(ILanguageModelsService, new class extends mock() { - override getLanguageModelIds() { return [testModelId]; } - override lookupLanguageModel(id: string) { return id === testModelId ? testMetadata : undefined; } - override lookupLanguageModelByQualifiedName(name: string) { - if (name === testModelQualifiedName) { - return { metadata: testMetadata, identifier: testModelId }; - } - return undefined; - } - override async selectLanguageModels() { return [testModelId]; } - }); - - // Mock IChatEditingService - instantiationService.stub(IChatEditingService, new class extends mock() { - override readonly editingSessionsObs = observableValue('sessions', []); - }); - - // Mock IInlineChatSessionService - const onDidChangeSessionsEmitter = store.add(new Emitter()); - const sessionStateObs = observableValue('terminationState', undefined); - const entriesObs = observableValue('entries', []); - - instantiationService.stub(IInlineChatSessionService, new class extends mock() { - override readonly onWillStartSession = Event.None; - override readonly onDidChangeSessions = onDidChangeSessionsEmitter.event; - override getSessionByTextModel() { return undefined; } - override getSessionBySessionUri() { return undefined; } - override createSession(_editor: any): IInlineChatSession2 { - const session: IInlineChatSession2 = { - initialPosition: new Position(1, 1), - initialSelection: _editor.getSelection() ?? new Selection(1, 1, 1, 6), - uri: _editor.getModel()!.uri, - chatModel: { - sessionResource: testSessionResource, - initialLocation: ChatAgentLocation.EditorInline, - hasRequests: false, - inputModel: { state: observableValue('state', undefined), setState: () => { }, clearState: () => { }, toJSON: () => ({}) }, - getRequests: () => [], - lastRequestObs: observableValue('lastReq', undefined), - onDidChange: Event.None, - } as unknown as IChatModel, - editingSession: { - onDidDispose: sessionDisposedEmitter.event, - entries: entriesObs, - readEntry: () => undefined, - getEntry: () => undefined, - accept: async () => { }, - reject: async () => { }, - dispose: () => { }, - } as Partial as IChatEditingSession, - terminationState: sessionStateObs, - setTerminationState: () => { }, - dispose: () => { - onDidChangeSessionsEmitter.fire(undefined); - }, - }; - onDidChangeSessionsEmitter.fire(undefined); - return session; - } - }); - - model = store.add(createTextModel('hello world\nfoo bar\nbaz qux')); - editor = store.add(instantiateTestCodeEditor(instantiationService, model)); - }); - - teardown(() => { - store.clear(); - }); - - ensureNoDisposablesAreLeakedInTestSuite(); - - function setExplicitSelection(sel: Selection): void { - editor.getViewModel()!.setCursorStates( - 'test', - CursorChangeReason.Explicit, - [CursorState.fromModelSelection(sel)] - ); - } - - test('hover mode sendRequest has correct location and locationData', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - setExplicitSelection(new Selection(1, 1, 1, 6)); - - const controller = store.add(instantiationService.createInstance(InlineChatController, editor)); - - const runPromise = controller.run({ message: 'test message', autoSend: true }); - await timeout(0); - - // Settle the session so run() can return - sessionDisposedEmitter.fire(); - await runPromise; - - assert.strictEqual(sendRequestCalls.length, 1, 'should have exactly one sendRequest call'); - const call = sendRequestCalls[0]; - - // Verify session resource - assert.ok(call.sessionResource.toString() === testSessionResource.toString()); - - // Verify message - assert.strictEqual(call.message, 'test message'); - - // Verify location - assert.strictEqual(call.options?.location, ChatAgentLocation.EditorInline); - - // Verify locationData - const locData = call.options?.locationData; - assert.ok(locData); - assert.strictEqual(locData.type, ChatAgentLocation.EditorInline); - if (locData.type === ChatAgentLocation.EditorInline) { - assert.ok(locData.document.toString() === model.uri.toString()); - assert.deepStrictEqual(Selection.liftSelection(locData.selection), new Selection(1, 1, 1, 6)); - } - - // Verify model selection - assert.strictEqual(call.options?.userSelectedModelId, testModelId); - - // Verify modeInfo - assert.strictEqual(call.options?.modeInfo?.kind, ChatModeKind.Ask); - assert.strictEqual(call.options?.modeInfo?.modeId, 'ask'); - assert.strictEqual(call.options?.modeInfo?.isBuiltin, true); - })); - - test('hover mode sendRequest locationData matches what zone widget resolveData would produce', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - setExplicitSelection(new Selection(2, 1, 2, 4)); - - const controller = store.add(instantiationService.createInstance(InlineChatController, editor)); - - const runPromise = controller.run({ message: 'edit code', autoSend: true }); - await timeout(0); - sessionDisposedEmitter.fire(); - await runPromise; - - assert.strictEqual(sendRequestCalls.length, 1); - const locData = sendRequestCalls[0].options?.locationData; - assert.ok(locData); - - // The zone widget's resolveData builds the same shape: - // { type: ChatAgentLocation.EditorInline, id: getEditorId(editor, model), selection, document, wholeRange } - if (locData.type === ChatAgentLocation.EditorInline) { - // id should be `${editorId},${modelId}` - assert.ok(typeof locData.id === 'string'); - assert.ok(locData.id.length > 0); - // document should match the editor's model URI - assert.ok(locData.document.toString() === model.uri.toString()); - // selection should match what we set - assert.deepStrictEqual(Selection.liftSelection(locData.selection), new Selection(2, 1, 2, 4)); - // wholeRange should equal the selection (same as zone widget behavior) - assert.deepStrictEqual(Range.lift(locData.wholeRange), new Range(2, 1, 2, 4)); - } else { - assert.fail('Expected EditorInline location data'); - } - })); - - test('hover mode resolves model via defaultModel setting', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - // Reset _userSelectedModel static - // @ts-ignore accessing private static for test reset - InlineChatController._userSelectedModel = undefined; - - // Set a default model config - configurationService.setUserConfiguration(InlineChatConfigKeys.DefaultModel, testModelQualifiedName); - configurationService.onDidChangeConfigurationEmitter.fire(new class extends mock() { - override affectsConfiguration() { return true; } - }); - - setExplicitSelection(new Selection(1, 1, 1, 6)); - const controller = store.add(instantiationService.createInstance(InlineChatController, editor)); - - const runPromise = controller.run({ message: 'hello', autoSend: true }); - await timeout(0); - sessionDisposedEmitter.fire(); - await runPromise; - - assert.strictEqual(sendRequestCalls.length, 1); - assert.strictEqual(sendRequestCalls[0].options?.userSelectedModelId, testModelId); - })); - - test('hover mode does not send request when autoSend is false', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - setExplicitSelection(new Selection(1, 1, 1, 6)); - const controller = store.add(instantiationService.createInstance(InlineChatController, editor)); - - const runPromise = controller.run({ message: 'hello', autoSend: false }); - await timeout(0); - sessionDisposedEmitter.fire(); - await runPromise; - - assert.strictEqual(sendRequestCalls.length, 0, 'should not call sendRequest when autoSend is false'); - })); - - test('hover mode does not send request when message is missing', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - setExplicitSelection(new Selection(1, 1, 1, 6)); - const controller = store.add(instantiationService.createInstance(InlineChatController, editor)); - - const runPromise = controller.run({ autoSend: true }); - await timeout(0); - sessionDisposedEmitter.fire(); - await runPromise; - - assert.strictEqual(sendRequestCalls.length, 0, 'should not call sendRequest when message is missing'); - })); - - test('hover mode sends request with diagnostics when attachDiagnostics is true', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - setExplicitSelection(new Selection(1, 1, 1, 6)); - - // Stub IMarkerDecorationsService to return a marker in the selection range - const testMarker: IMarker = { - owner: 'testOwner', - resource: model.uri, - severity: MarkerSeverity.Error, - message: 'test error', - startLineNumber: 1, - startColumn: 1, - endLineNumber: 1, - endColumn: 6, - }; - instantiationService.stub(IMarkerDecorationsService, new class extends mock() { - override getLiveMarkers(uri: URI): [Range, IMarker][] { - if (uri.toString() === model.uri.toString()) { - return [[new Range(1, 1, 1, 6), testMarker]]; - } - return []; - } - }); - - const controller = store.add(instantiationService.createInstance(InlineChatController, editor)); - - const runPromise = controller.run({ autoSend: true, attachDiagnostics: true }); - await timeout(0); - sessionDisposedEmitter.fire(); - await runPromise; - - assert.strictEqual(sendRequestCalls.length, 1, 'should call sendRequest when attachDiagnostics is true and diagnostics exist'); - assert.ok(sendRequestCalls[0].message, 'should have a message'); - assert.ok(sendRequestCalls[0].options?.attachedContext, 'should have attached context with diagnostics'); - assert.strictEqual(sendRequestCalls[0].options!.attachedContext!.length, 1, 'should have exactly one diagnostic entry'); - })); -}); diff --git a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatZoneMenus.test.ts b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatZoneMenus.test.ts index 09003db02627f..e15de3a162263 100644 --- a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatZoneMenus.test.ts +++ b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatZoneMenus.test.ts @@ -8,7 +8,7 @@ import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { isIMenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { ContextKeyValue, IContext } from '../../../../../platform/contextkey/common/contextkey.js'; -import { KeepSessionAction2, UndoSessionAction2, UndoAndCloseSessionAction2, CancelSessionAction, ContinueInlineChatInChatViewAction, RephraseInlineChatSessionAction, } from '../../browser/inlineChatActions.js'; +import { KeepSessionAction2, UndoAndCloseSessionAction2, CancelSessionAction, ContinueInlineChatInChatViewAction, RephraseInlineChatSessionAction, } from '../../browser/inlineChatActions.js'; import { registerChatExecuteActions } from '../../../chat/browser/actions/chatExecuteActions.js'; import { registerChatContextActions } from '../../../chat/browser/actions/chatContextActions.js'; import { registerChatToolActions } from '../../../chat/browser/actions/chatToolActions.js'; @@ -37,7 +37,6 @@ suite('Inline chat zone widget — menu contributions', function () { disposables.add(registerChatToolActions()); disposables.add(registerAction2(KeepSessionAction2)); - disposables.add(registerAction2(UndoSessionAction2)); disposables.add(registerAction2(UndoAndCloseSessionAction2)); disposables.add(registerAction2(CancelSessionAction)); disposables.add(registerAction2(ContinueInlineChatInChatViewAction)); @@ -102,22 +101,10 @@ suite('Inline chat zone widget — menu contributions', function () { ].sort()); }); - test('ChatEditorInlineExecute — request in progress (hover mode)', () => { + test('ChatEditorInlineExecute — request in progress', () => { const ctx = createContext({ 'chatEdits.isRequestInProgress': true, 'chatSessionHasActiveRequest': true, - 'config.inlineChat.renderMode': 'hover', - }); - assert.deepStrictEqual(visibleIds(MenuId.ChatEditorInlineExecute, ctx), [ - 'inlineChat2.cancel', - ].sort()); - }); - - test('ChatEditorInlineExecute — request in progress (zone mode)', () => { - const ctx = createContext({ - 'chatEdits.isRequestInProgress': true, - 'chatSessionHasActiveRequest': true, - 'config.inlineChat.renderMode': 'zone', }); assert.deepStrictEqual(visibleIds(MenuId.ChatEditorInlineExecute, ctx), [ 'inlineChat2.close', @@ -125,27 +112,12 @@ suite('Inline chat zone widget — menu contributions', function () { ].sort()); }); - test('ChatEditorInlineExecute — completed with edits, no text (hover mode)', () => { - const ctx = createContext({ - 'chatEdits.hasEditorModifications': true, - 'chatEdits.isRequestInProgress': false, - 'chatSessionHasActiveRequest': false, - 'chatInputHasText': false, - 'config.inlineChat.renderMode': 'hover', - }); - assert.deepStrictEqual(visibleIds(MenuId.ChatEditorInlineExecute, ctx), [ - 'inlineChat2.keep', - 'inlineChat2.undo', - ].sort()); - }); - test('ChatEditorInlineExecute — terminated', () => { const ctx = createContext({ 'inlineChatTerminated': true, 'chatEdits.hasEditorModifications': false, 'chatEdits.isRequestInProgress': false, 'chatSessionHasActiveRequest': false, - 'config.inlineChat.renderMode': 'hover', }); assert.deepStrictEqual(visibleIds(MenuId.ChatEditorInlineExecute, ctx), [ 'inlineChat2.close', diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatAffordance.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatAffordance.fixture.ts index 20db94f5be9bb..e736ef737b98e 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatAffordance.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatAffordance.fixture.ts @@ -8,8 +8,6 @@ import { Selection } from '../../../../../editor/common/core/selection.js'; import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; import { ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; import { InlineChatEditorAffordance } from '../../../../contrib/inlineChat/browser/inlineChatEditorAffordance.js'; -import { InlineChatInputWidget } from '../../../../contrib/inlineChat/browser/inlineChatOverlayWidget.js'; -import { IInlineChatHistoryService } from '../../../../contrib/inlineChat/browser/inlineChatHistoryService.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { ContextKeyService } from '../../../../../platform/contextkey/browser/contextKeyService.js'; import { EditorContextKeys } from '../../../../../editor/common/editorContextKeys.js'; @@ -17,13 +15,15 @@ import { IMenuService, MenuId, MenuRegistry } from '../../../../../platform/acti import { MenuService } from '../../../../../platform/actions/common/menuService.js'; import { ChatContextKeys } from '../../../../contrib/chat/common/actions/chatContextKeys.js'; import { Codicon } from '../../../../../base/common/codicons.js'; +import { observableCodeEditor } from '../../../../../editor/browser/observableCodeEditor.js'; + + // Register menu items import '../../../../contrib/inlineChat/browser/inlineChatActions.js'; import '../../../../../editor/contrib/codeAction/browser/codeActionContributions.js'; import '../../../../contrib/inlineChat/browser/media/inlineChatEditorAffordance.css'; import '../../../../../base/browser/ui/codicons/codiconStyles.js'; -import { observableCodeEditor } from '../../../../../editor/browser/observableCodeEditor.js'; const SAMPLE_CODE = `function greet(name: string): string { return "Hello, " + name; @@ -95,96 +95,9 @@ function renderInlineChatAffordance({ container, disposableStore, theme }: Compo )); } -function renderInlineChatOverlay({ container, disposableStore, theme }: ComponentFixtureContext): void { - container.style.width = '500px'; - container.style.height = '280px'; - container.style.border = '1px solid var(--vscode-editorWidget-border)'; - - // Register fake menu items scoped to this fixture's lifetime - disposableStore.add(MenuRegistry.appendMenuItem(MenuId.ChatEditorInlineMenu, { - group: '0_chat', order: 2, when: ChatContextKeys.enabled, - command: { id: 'workbench.action.chat.attachSelection', title: 'Add Selection to Chat' }, - })); - disposableStore.add(MenuRegistry.appendMenuItem(MenuId.ChatEditorInlineMenu, { - group: '1_actions', order: 1, - command: { id: 'inlineChat.explain', title: 'Explain' }, - })); - disposableStore.add(MenuRegistry.appendMenuItem(MenuId.ChatEditorInlineMenu, { - group: '1_actions', order: 2, - command: { id: 'inlineChat.review', title: 'Review' }, - })); - - const instantiationService = createEditorServices(disposableStore, { - colorTheme: theme, - additionalServices: (reg) => { - registerWorkbenchServices(reg); - reg.define(IContextKeyService, ContextKeyService); - reg.define(IMenuService, MenuService); - reg.definePartialInstance(IInlineChatHistoryService, { - _serviceBrand: undefined, - addToHistory: () => { }, - previousValue: () => undefined, - nextValue: () => undefined, - isAtEnd: () => true, - replaceLast: () => { }, - resetCursor: () => { }, - }); - }, - }); - - const textModel = disposableStore.add(createTextModel( - instantiationService, - `const { - addCard, - updateCard, - deleteCard, - moveCard, - addLabel, - deleteLabel, - updateLabel, -} = useAppState(); - -return ( -
`, - URI.parse('inmemory://inline-chat-overlay.tsx'), - 'typescriptreact' - )); - - const editor = disposableStore.add(instantiationService.createInstance( - CodeEditorWidget, - container, - { - automaticLayout: true, - minimap: { enabled: false }, - lineNumbers: 'on', - scrollBeyondLastLine: false, - fontSize: 14, - cursorBlinking: 'solid', - }, - { contributions: [] } satisfies ICodeEditorWidgetOptions - )); - - editor.setModel(textModel); - - const contextKeyService = instantiationService.get(IContextKeyService); - ChatContextKeys.enabled.bindTo(contextKeyService).set(true); - EditorContextKeys.hasNonEmptySelection.bindTo(contextKeyService).set(true); - - editor.setSelection(new Selection(2, 1, 8, 15)); - editor.focus(); - - const editorObs = observableCodeEditor(editor); - const inputWidget = disposableStore.add(instantiationService.createInstance(InlineChatInputWidget, editorObs)); - inputWidget.show(8, 160, false, 'Describe how to change this'); -} - export default defineThemedFixtureGroup({ path: 'editor/' }, { InlineChatAffordance: defineComponentFixture({ labels: { kind: 'screenshot' }, render: (context) => renderInlineChatAffordance(context, true), }), - InlineChatOverlay: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: (context) => renderInlineChatOverlay(context), - }), }); From ffb4db20eb4573d4f9c4c2c5fc271738a8a02f77 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:15:47 +0000 Subject: [PATCH 22/32] Agents - only show "Sync Pull Request" primary action if there are incoming/outgoing/uncommitted changes (#312126) --- extensions/copilot/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index e64decdf0498e..c5024d36f75d8 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -5040,7 +5040,7 @@ }, { "command": "github.copilot.chat.createPullRequestCopilotCLIAgentSession.updatePR", - "when": "chatSessionType == copilotcli && isSessionsWindow && sessions.isolationMode == worktree && sessions.hasGitRepository && sessions.hasGitHubRemote && sessions.hasPullRequest && sessions.hasOpenPullRequest", + "when": "chatSessionType == copilotcli && isSessionsWindow && sessions.isolationMode == worktree && sessions.hasGitRepository && sessions.hasGitHubRemote && sessions.hasPullRequest && sessions.hasOpenPullRequest && (sessions.hasIncomingChanges || sessions.hasOutgoingChanges || sessions.hasUncommittedChanges)", "group": "pull_request@1" }, { From a425fe8f379bb216b13c5367ebbe627c01068d7d Mon Sep 17 00:00:00 2001 From: Johannes Date: Thu, 23 Apr 2026 16:23:15 +0200 Subject: [PATCH 23/32] inlineChat: remove stale renderMode setting reference from sessions defaults --- .../contrib/configuration/browser/configuration.contribution.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts index 68a1b81b00ad5..d28853194f27e 100644 --- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -73,7 +73,6 @@ Registry.as(Extensions.Configuration).registerDefaultCon 'github.copilot.chat.cli.showExternalSessions': false, 'inlineChat.affordance': 'editor', - 'inlineChat.renderMode': 'hover', 'search.quickOpen.includeHistory': false, From f36a9769269cad25b8211d6f7e0c802607b90405 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Thu, 23 Apr 2026 15:30:03 +0100 Subject: [PATCH 24/32] Fix padding for action list filter row in agent sessions workbench (#312135) Co-authored-by: mrleemurray --- src/vs/sessions/browser/media/style.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 22f41207b4f89..5b6c61e90d0a0 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -470,6 +470,10 @@ background-color: var(--vscode-agentsPanel-background); } +.agent-sessions-workbench .action-widget .action-list-filter-row { + padding-right: 0; +} + .agent-sessions-workbench .action-widget .monaco-scrollable-element > .monaco-list-rows { background-color: var(--vscode-agentsPanel-background) !important; } From 677ee01878ea3db4c60cfe140a5fdddb4d64beb0 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 24 Apr 2026 00:31:06 +1000 Subject: [PATCH 25/32] Refactor chat session worktree handling and remove unused components (#312109) * Refactor chat session worktree handling and remove unused components Co-authored-by: Copilot * Updates * Updates * Updates * Updats Co-authored-by: Copilot * Updates * Fixes * Refactor session working directory management to use chat session metadata store Co-authored-by: Copilot * Updates Co-authored-by: Copilot --------- Co-authored-by: Copilot --- .../common/chatSessionMetadataStore.ts | 12 +- .../common/chatSessionWorktreeService.ts | 3 - .../test/mockChatSessionMetadataStore.ts | 34 ++- .../copilotcli/node/cliHelpers.ts | 10 - .../chatSessionMetadataStoreImpl.ts | 269 ++++++------------ .../chatSessionRepositoryTracker.ts | 40 +-- .../chatSessionWorktreeServiceImpl.ts | 40 +-- .../vscode-node/copilotCLIChatSessions.ts | 30 +- .../copilotCLIChatSessionsContribution.ts | 25 +- .../folderRepositoryManagerImpl.ts | 19 +- .../test/chatSessionMetadataStoreImpl.spec.ts | 229 --------------- .../copilotCLIChatSessionParticipant.spec.ts | 3 +- .../test/folderRepositoryManager.spec.ts | 59 +++- .../test/worktreeSessionIndex.spec.ts | 190 ------------- .../vscode-node/worktreeSessionIndex.ts | 221 -------------- .../agentSessions/agentSessionsModel.ts | 39 ++- 16 files changed, 257 insertions(+), 966 deletions(-) delete mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/test/worktreeSessionIndex.spec.ts delete mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/worktreeSessionIndex.ts diff --git a/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts b/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts index e38c94ebdffa0..0f8a4e2bada73 100644 --- a/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts +++ b/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import type * as vscode from 'vscode'; -import type { Uri } from 'vscode'; import { createServiceIdentifier } from '../../../util/common/services'; import { ChatSessionWorktreeProperties } from './chatSessionWorktreeService'; import type { IWorkspaceInfo } from './workspaceInfo'; @@ -118,9 +117,7 @@ export interface IChatSessionMetadataStore { storeWorkspaceFolderInfo(sessionId: string, entry: WorkspaceFolderEntry): Promise; storeRepositoryProperties(sessionId: string, properties: RepositoryProperties): Promise; getRepositoryProperties(sessionId: string): Promise; - getSessionIdForWorktree(folder: vscode.Uri): Promise; getWorktreeProperties(sessionId: string): Promise; - getWorktreeProperties(folder: Uri): Promise; getSessionWorkspaceFolder(sessionId: string): Promise; getSessionWorkspaceFolderEntry(sessionId: string): Promise; getAdditionalWorkspaces(sessionId: string): Promise; @@ -147,4 +144,13 @@ export interface IChatSessionMetadataStore { * on demand. Concurrent calls collapse: at most one in-flight + one pending. */ refresh(): Promise; + /** + * Returns session IDs whose working directory (worktree path or workspace folder) + * matches the given folder URI. + */ + getSessionIdsForFolder(folder: vscode.Uri): string[]; + /** + * Returns session IDs that have a worktree whose path matches the given folder URI. + */ + getWorktreeSessions(folder: vscode.Uri): string[]; } diff --git a/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts b/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts index e26a50bf2c476..ea4c702b72f48 100644 --- a/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts +++ b/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts @@ -63,7 +63,6 @@ export interface IChatSessionWorktreeService { createWorktree(repositoryPath: vscode.Uri, stream?: vscode.ChatResponseStream, baseBranch?: string, branchName?: string): Promise; getWorktreeProperties(sessionId: string): Promise; - getWorktreeProperties(folder: vscode.Uri): Promise; setWorktreeProperties(sessionId: string, properties: string | ChatSessionWorktreeProperties): Promise; getWorktreeRepository(sessionId: string): Promise; @@ -71,8 +70,6 @@ export interface IChatSessionWorktreeService { applyWorktreeChanges(sessionId: string): Promise; - getSessionIdForWorktree(folder: vscode.Uri): Promise; - getWorktreeChanges(sessionId: string): Promise; hasCachedChanges(sessionId: string): Promise; diff --git a/extensions/copilot/src/extension/chatSessions/common/test/mockChatSessionMetadataStore.ts b/extensions/copilot/src/extension/chatSessions/common/test/mockChatSessionMetadataStore.ts index 96aa2ee811ec1..ed442e5a4e304 100644 --- a/extensions/copilot/src/extension/chatSessions/common/test/mockChatSessionMetadataStore.ts +++ b/extensions/copilot/src/extension/chatSessions/common/test/mockChatSessionMetadataStore.ts @@ -54,11 +54,8 @@ export class MockChatSessionMetadataStore implements IChatSessionMetadataStore { return undefined; } - async getWorktreeProperties(sessionIdOrFolder: string | vscode.Uri): Promise { - if (typeof sessionIdOrFolder === 'string') { - return this._worktreeProperties.get(sessionIdOrFolder); - } - return undefined; + async getWorktreeProperties(sessionId: string): Promise { + return this._worktreeProperties.get(sessionId); } async getSessionWorkspaceFolder(_sessionId: string): Promise { @@ -155,4 +152,31 @@ export class MockChatSessionMetadataStore implements IChatSessionMetadataStore { getSessionParentId(_sessionId: string): Promise { return Promise.resolve(undefined); } + + getSessionIdsForFolder(folder: vscode.Uri): string[] { + const folderPath = folder.fsPath; + const sessionIds: string[] = []; + for (const [sessionId, props] of this._worktreeProperties) { + if (props.worktreePath === folderPath) { + sessionIds.push(sessionId); + } + } + for (const [sessionId, entry] of this._workspaceFolders) { + if (entry.folderPath === folderPath && !sessionIds.includes(sessionId)) { + sessionIds.push(sessionId); + } + } + return sessionIds; + } + + getWorktreeSessions(folder: vscode.Uri): string[] { + const folderPath = folder.fsPath; + const sessionIds: string[] = []; + for (const [sessionId, props] of this._worktreeProperties) { + if (props.worktreePath === folderPath) { + sessionIds.push(sessionId); + } + } + return sessionIds; + } } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/cliHelpers.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/cliHelpers.ts index ea9074ae5d3bc..f8a612d248b6f 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/cliHelpers.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/cliHelpers.ts @@ -44,13 +44,3 @@ export function getCopilotCLIWorkspaceFile(sessionId: string) { export function getCopilotBulkMetadataFile(): string { return join(getCopilotHome(), 'vscode.session.metadata.cache.json'); } - -/** - * Path of the shared worktree-sessions JSONL index. Append-only, one - * {@link WorktreeSessionEntry} per line. - * Used as a worktree folder → session-id fallback - * when an entry has been evicted from the bulk cache. - */ -export function getCopilotWorktreeSessionsFile(): string { - return join(getCopilotHome(), 'vscode.session.worktree.jsonl'); -} diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionMetadataStoreImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionMetadataStoreImpl.ts index fc5790906f792..5c3b1c02af21a 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionMetadataStoreImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionMetadataStoreImpl.ts @@ -11,22 +11,19 @@ import { ILogService } from '../../../platform/log/common/logService'; import { findLast } from '../../../util/vs/base/common/arraysFind'; import { SequencerByKey, ThrottledDelayer } from '../../../util/vs/base/common/async'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; -import { dirname, isEqual } from '../../../util/vs/base/common/resources'; -import { ChatSessionMetadataFile, IChatSessionMetadataStore, RepositoryProperties, RequestDetails, WorkspaceFolderEntry, WorktreeSessionEntry } from '../common/chatSessionMetadataStore'; +import { dirname } from '../../../util/vs/base/common/resources'; +import { ChatSessionMetadataFile, IChatSessionMetadataStore, RepositoryProperties, RequestDetails, WorkspaceFolderEntry } from '../common/chatSessionMetadataStore'; import { ChatSessionWorktreeProperties } from '../common/chatSessionWorktreeService'; import { isUntitledSessionId } from '../common/utils'; import { IWorkspaceInfo } from '../common/workspaceInfo'; -import { getCopilotBulkMetadataFile, getCopilotCLISessionDir, getCopilotCLISessionStateDir, getCopilotWorktreeSessionsFile } from '../copilotcli/node/cliHelpers'; +import { getCopilotBulkMetadataFile, getCopilotCLISessionDir } from '../copilotcli/node/cliHelpers'; import { ICopilotCLIAgents } from '../copilotcli/node/copilotCli'; -import { WorktreeSessionIndex } from './worktreeSessionIndex'; // const WORKSPACE_FOLDER_MEMENTO_KEY = 'github.copilot.cli.sessionWorkspaceFolders'; // const WORKTREE_MEMENTO_KEY = 'github.copilot.cli.sessionWorktrees'; const LEGACY_BULK_METADATA_FILENAME = 'copilotcli.session.metadata.json'; const LEGACY_BULK_MIGRATED_KEY = 'github.copilot.cli.legacyBulkMigrated'; -const JSONL_SCAN_DONE_KEY = 'github.copilot.cli.events.jsonl.scaned'; const REQUEST_MAPPING_FILENAME = 'vscode.requests.metadata.json'; -const SESSION_SCAN_BATCH_SIZE = 20; /** * Maximum number of sessions kept in the shared bulk metadata cache file @@ -49,8 +46,10 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession */ private _cache: Record = {}; - /** Maps session id → JSONL entry and folder path → session id. Owns JSONL file persistence. */ - private readonly _worktreeSessions: WorktreeSessionIndex; + /** Session ID → indexed path and kind, for reverse-lookup cleanup. */ + private readonly _sessionFolderEntry = new Map(); + /** Folder path → set of session IDs (worktree path or workspace folder path). */ + private readonly _folderToSessions = new Map>(); /** Path of the shared bulk metadata cache file in `~/.copilot/`. */ private readonly _cacheFile = Uri.file(getCopilotBulkMetadataFile()); @@ -77,12 +76,6 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession ) { super(); - this._worktreeSessions = new WorktreeSessionIndex( - this.fileSystemService, - this.logService, - getCopilotWorktreeSessionsFile(), - ); - this._ready = this.initializeStorage(); this._ready.catch(error => { this.logService.error('[ChatSessionMetadataStore] Initialization failed: ', error); @@ -97,6 +90,65 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession return this._ready; } + public getSessionIdsForFolder(folder: vscode.Uri): string[] { + return Array.from(this._folderToSessions.get(folder.fsPath) ?? []); + } + + public getWorktreeSessions(folder: vscode.Uri): string[] { + const sessions = this._folderToSessions.get(folder.fsPath); + if (!sessions) { + return []; + } + const result: string[] = []; + for (const sessionId of sessions) { + if (this._sessionFolderEntry.get(sessionId)?.kind === 'worktree') { + result.push(sessionId); + } + } + return result; + } + + /** + * Maintains {@link _sessionFolderEntry} and {@link _folderToSessions} so + * that {@link getSessionIdsForFolder} and {@link getWorktreeSessions} + * are O(1) lookups instead of full-cache scans. + */ + private _updateFolderIndex(sessionId: string, metadata: ChatSessionMetadataFile | undefined): void { + // Remove old entry + const old = this._sessionFolderEntry.get(sessionId); + if (old) { + const set = this._folderToSessions.get(old.path); + if (set) { + set.delete(sessionId); + if (set.size === 0) { + this._folderToSessions.delete(old.path); + } + } + this._sessionFolderEntry.delete(sessionId); + } + + if (!metadata) { + return; + } + + // Prefer worktree path over workspace folder path + const worktreePath = metadata.worktreeProperties?.worktreePath; + const folderPath = metadata.workspaceFolder?.folderPath; + const path = worktreePath ?? folderPath; + if (!path) { + return; + } + + const kind: 'worktree' | 'folder' = worktreePath ? 'worktree' : 'folder'; + this._sessionFolderEntry.set(sessionId, { path, kind }); + let set = this._folderToSessions.get(path); + if (!set) { + set = new Set(); + this._folderToSessions.set(path, set); + } + set.add(sessionId); + } + private async initializeStorage(): Promise { // One-time migration from the legacy per-install bulk file in // globalStorageUri to the shared `~/.copilot/` location. @@ -115,14 +167,13 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession } } + // Build folder index from the cleaned cache. + for (const [sessionId, metadata] of Object.entries(this._cache)) { + this._updateFolderIndex(sessionId, metadata); + } + // this.extensionContext.globalState.update(WORKTREE_MEMENTO_KEY, undefined); // this.extensionContext.globalState.update(WORKSPACE_FOLDER_MEMENTO_KEY, undefined); - - - // Ensure every cached session with a worktreePath has a JSONL - // entry. Only appends entries that are missing; falls back to a full rewrite when - // the load detected duplicates or malformed lines. - await this.topUpJsonlIndexFromCache(); } public getMetadataFileUri(sessionId: string): vscode.Uri { @@ -137,13 +188,13 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession await this._ready; if (sessionId in this._cache) { delete this._cache[sessionId]; + this._updateFolderIndex(sessionId, undefined); const data = await this.getGlobalStorageData().catch(() => ({} as Record)); delete data[sessionId]; await this.writeToGlobalStorage(data); } try { await Promise.allSettled([ - this._worktreeSessions.removeAndWriteToDisk(sessionId), this.fileSystemService.delete(this.getMetadataFileUri(sessionId)), this.fileSystemService.delete(this.getRequestMappingFileUri(sessionId)) ]); @@ -163,6 +214,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession // cannot stomp fields written by other processes (Step 3b: stale-cache fix). const existing = this._cache[sessionId] ?? {}; this._cache[sessionId] = { ...existing, ...fields }; + this._updateFolderIndex(sessionId, this._cache[sessionId]); await this.updateSessionMetadata(sessionId, fields); this.updateGlobalStorage(); } @@ -184,47 +236,10 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession return metadata?.repositoryProperties; } - getWorktreeProperties(sessionId: string): Promise; - getWorktreeProperties(folder: Uri): Promise; - async getWorktreeProperties(sessionId: string | Uri): Promise { + async getWorktreeProperties(sessionId: string): Promise { await this._ready; - if (typeof sessionId === 'string') { - const metadata = await this.getSessionMetadata(sessionId); - return metadata?.worktreeProperties; - } - const folder = sessionId; - // First check the in-memory cache. - for (const metadata of Object.values(this._cache)) { - if (metadata.worktreeProperties?.worktreePath && isEqual(Uri.file(metadata.worktreeProperties.worktreePath), folder)) { - return metadata.worktreeProperties; - } - } - // Fallback to the JSONL worktree index → hydrate from the per-session file. - const id = await this.findSessionIdForWorktree(folder); - if (id) { - const metadata = await this.getSessionMetadata(id); - return metadata?.worktreeProperties; - } - return undefined; - } - async getSessionIdForWorktree(folder: vscode.Uri): Promise { - await this._ready; - for (const [sessionId, value] of Object.entries(this._cache)) { - if (value.worktreeProperties?.worktreePath && isEqual(vscode.Uri.file(value.worktreeProperties.worktreePath), folder)) { - return sessionId; - } - } - return this.findSessionIdForWorktree(folder); - } - - /** Looks up a session id for a worktree folder via the JSONL index, with a throttled disk reload. */ - private async findSessionIdForWorktree(folder: vscode.Uri): Promise { - const cached = this._worktreeSessions.getSessionIdForFolder(folder); - if (cached) { - return cached; - } - await this._worktreeSessions.reloadIfStale(); - return this._worktreeSessions.getSessionIdForFolder(folder); + const metadata = await this.getSessionMetadata(sessionId); + return metadata?.worktreeProperties; } async getSessionWorkspaceFolder(sessionId: string): Promise { @@ -397,6 +412,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession const metadata = await this.readSessionMetadataFile(sessionId); if (metadata) { this._cache[sessionId] = metadata; + this._updateFolderIndex(sessionId, metadata); return metadata; } @@ -446,6 +462,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession if (!createDirectoryIfNotFound) { // Lets not delete the session from our storage, but mark it as written to session state so that we won't try to write to session state again and again. this._cache[sessionId] = { ...updates, writtenToDisc: true }; + this._updateFolderIndex(sessionId, this._cache[sessionId]); this.updateGlobalStorage(); return; } @@ -475,31 +492,11 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession merged.created = now; } - const promises: Promise[] = []; - - // Maintain the JSONL worktree index based on the post-merge worktreePath: - // - new entry → append a line and remember it - // - changed path → rewrite the file (rare) - // - cleared path → remove via rewrite - const worktreePath = merged.worktreeProperties?.worktreePath; - const indexed = this._worktreeSessions.getSessionEntry(sessionId); - if (worktreePath) { - if (!indexed) { - promises.push(this._worktreeSessions.appendBatchToDisk([{ id: sessionId, path: worktreePath, created: merged.created }])); - } else if (indexed.path !== worktreePath && !merged.kind) { - this._worktreeSessions.addEntry({ id: sessionId, path: worktreePath, created: indexed.created }); - promises.push(this._worktreeSessions.writeToDisk()); - } - } else if (indexed) { - promises.push(this._worktreeSessions.removeAndWriteToDisk(sessionId)); - } - const content = new TextEncoder().encode(JSON.stringify(merged, null, 2)); - promises.push(this.fileSystemService.writeFile(fileUri, content)); - - await Promise.all(promises); + await this.fileSystemService.writeFile(fileUri, content); this._cache[sessionId] = { ...merged, writtenToDisc: true }; + this._updateFolderIndex(sessionId, this._cache[sessionId]); this.updateGlobalStorage(); this.logService.trace(`[ChatSessionMetadataStore] Wrote metadata for session ${sessionId}`); }); @@ -529,6 +526,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession if (!local) { data[sessionId] = diskEntry; this._cache[sessionId] = diskEntry; + this._updateFolderIndex(sessionId, diskEntry); continue; } const localModified = local.modified ?? 0; @@ -536,6 +534,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession if (diskModified > localModified) { data[sessionId] = diskEntry; this._cache[sessionId] = diskEntry; + this._updateFolderIndex(sessionId, diskEntry); } } } catch { @@ -591,12 +590,14 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession const local = this._cache[id]; if (!local) { this._cache[id] = diskEntry; + this._updateFolderIndex(id, diskEntry); continue; } const localModified = local.modified ?? 0; const diskModified = diskEntry.modified ?? 0; if (diskModified > localModified) { this._cache[id] = diskEntry; + this._updateFolderIndex(id, diskEntry); } } }); @@ -668,106 +669,4 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession this.logService.error('[ChatSessionMetadataStore] Failed to migrate legacy bulk file: ', err); } } - - /** - * For every cached session with a `worktreePath`, ensure a JSONL entry exists. - */ - private async topUpJsonlIndexFromCache(): Promise { - // Load the JSONL worktree index from disk first so the scan below can - // tell which entries already exist and avoid re-appending duplicates. - let { rewriteNeeded } = await this._worktreeSessions.loadFromDisk(); - - const toAppend: WorktreeSessionEntry[] = []; - for (const [id, metadata] of Object.entries(this._cache)) { - const path = metadata.worktreeProperties?.worktreePath; - if (!path || metadata.kind) { - continue; - } - const existing = this._worktreeSessions.getSessionEntry(id); - if (existing && existing.path === path) { - continue; - } - const entry: WorktreeSessionEntry = { id, path, created: existing?.created ?? metadata.created ?? Date.now() }; - this._worktreeSessions.addEntry(entry); - if (existing) { - // Path changed — a full rewrite is needed. - rewriteNeeded = true; - } else { - toAppend.push(entry); - } - } - - if (rewriteNeeded) { - await this._worktreeSessions.writeToDisk(); - } else if (toAppend.length > 0) { - await this._worktreeSessions.appendBatchToDisk(toAppend); - } - - // One-time full scan of ~/.copilot/session-state/ to discover worktree - // sessions that were never recorded in the JSONL (e.g. sessions created - // before the JSONL index existed, or evicted from the bulk cache). - await this.scanSessionStateDirForWorktrees(); - } - - /** - * One-time scan of `~/.copilot/session-state/` to discover worktree sessions - * not yet in the JSONL index. Reads per-session metadata files in batches of - * {@link SESSION_SCAN_BATCH_SIZE} to avoid saturating I/O. Gated by a memento - * so it only runs once per install. - */ - private async scanSessionStateDirForWorktrees(): Promise { - if (this.extensionContext.globalState.get(JSONL_SCAN_DONE_KEY)) { - return; - } - - const sessionStateDir = Uri.file(getCopilotCLISessionStateDir()); - let entries: [string, number][]; - try { - entries = await this.fileSystemService.readDirectory(sessionStateDir); - } catch { - // Directory doesn't exist — nothing to scan. - await this.extensionContext.globalState.update(JSONL_SCAN_DONE_KEY, true); - return; - } - - // Collect session IDs we don't already know about. - const unknownIds: string[] = []; - for (const [name] of entries) { - if (name in this._cache || this._worktreeSessions.has(name)) { - continue; - } - unknownIds.push(name); - } - - if (unknownIds.length === 0) { - await this.extensionContext.globalState.update(JSONL_SCAN_DONE_KEY, true); - return; - } - - // Read metadata files in batches. - let discovered = false; - for (let i = 0; i < unknownIds.length; i += SESSION_SCAN_BATCH_SIZE) { - const batch = unknownIds.slice(i, i + SESSION_SCAN_BATCH_SIZE); - const results = await Promise.all(batch.map(async id => { - const metadata = await this.readSessionMetadataFile(id); - return { id, metadata }; - })); - for (const { id, metadata } of results) { - if (!metadata?.worktreeProperties?.worktreePath || metadata.kind) { - continue; - } - const path = metadata.worktreeProperties.worktreePath; - if (!this._worktreeSessions.has(id)) { - this._worktreeSessions.addEntry({ id, path, created: metadata.created ?? Date.now() }); - discovered = true; - } - } - } - - if (discovered) { - await this._worktreeSessions.writeToDisk(); - } - await this.extensionContext.globalState.update(JSONL_SCAN_DONE_KEY, true); - this.logService.info(`[ChatSessionMetadataStore] Session-state scan complete: checked ${unknownIds.length} unknown session(s)`); - } } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionRepositoryTracker.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionRepositoryTracker.ts index 01e7e8eb522c6..7bed0b04222c7 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionRepositoryTracker.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionRepositoryTracker.ts @@ -10,6 +10,7 @@ import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspa import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService'; import { ICopilotCLIChatSessionItemProvider } from './copilotCLIChatSessions'; import { IGitService } from '../../../platform/git/common/gitService'; +import { IChatSessionMetadataStore } from '../common/chatSessionMetadataStore'; export class ChatSessionRepositoryTracker extends Disposable { private readonly repositories = new DisposableResourceMap(); @@ -19,7 +20,8 @@ export class ChatSessionRepositoryTracker extends Disposable { @IChatSessionWorktreeService private readonly worktreeService: IChatSessionWorktreeService, @IChatSessionWorkspaceFolderService private readonly workspaceFolderService: IChatSessionWorkspaceFolderService, @IGitService private readonly gitService: IGitService, - @ILogService private readonly logService: ILogService + @ILogService private readonly logService: ILogService, + @IChatSessionMetadataStore private readonly metadataStore: IChatSessionMetadataStore ) { super(); @@ -69,33 +71,21 @@ export class ChatSessionRepositoryTracker extends Disposable { private async onDidChangeRepositoryState(uri: vscode.Uri): Promise { this.logService.trace(`[ChatSessionRepositoryTracker][onDidChangeRepositoryState] Repository state changed for ${uri.toString()}. Updating session properties.`); - const worktreeSessionId = await this.worktreeService.getSessionIdForWorktree(uri); + const sessionIds = await this.metadataStore.getSessionIdsForFolder(uri); const workspaceSessionIds = this.workspaceFolderService.clearWorkspaceChanges(uri); - - if (worktreeSessionId) { + sessionIds.push(...workspaceSessionIds); + await Promise.all(Array.from(new Set(sessionIds)).map(async sessionId => { // Worktree - const worktreeProperties = await this.worktreeService.getWorktreeProperties(worktreeSessionId); - if (!worktreeProperties) { - return; + const worktreeProperties = await this.worktreeService.getWorktreeProperties(sessionId); + if (worktreeProperties) { + await this.worktreeService.setWorktreeProperties(sessionId, { + ...worktreeProperties, + changes: undefined + }); } - - await this.worktreeService.setWorktreeProperties(worktreeSessionId, { - ...worktreeProperties, - changes: undefined - }); - - await this.sessionItemProvider.refreshSession({ reason: 'update', sessionId: worktreeSessionId }); - this.logService.trace(`[ChatSessionRepositoryTracker][onDidChangeRepositoryState] Updated session properties for worktree ${uri.toString()}.`); - } else if (workspaceSessionIds.length > 0) { - // Workspace - // This is still using the old ChatSessionItem API so there is no need to refresh each session - // associated with the workspace folder. When the new controller API is fully adopted we will - // have to refresh each session. - await this.sessionItemProvider.refreshSession({ reason: 'update', sessionIds: workspaceSessionIds }); - this.logService.trace(`[ChatSessionRepositoryTracker][onDidChangeRepositoryState] Updated session properties for workspace ${uri.toString()}.`); - } else { - this.logService.trace(`[ChatSessionRepositoryTracker][onDidChangeRepositoryState] No session associated with workspace ${uri.toString()}.`); - } + })); + await this.sessionItemProvider.refreshSession({ reason: 'update', sessionIds }); + this.logService.trace(`[ChatSessionRepositoryTracker][onDidChangeRepositoryState] Updated session properties for worktree ${uri.toString()}.`); } private disposeRepositoryWatcher(uri: vscode.Uri): void { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts index 5c2f599121fdc..715ef295eea99 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts @@ -18,7 +18,6 @@ import { ILogService } from '../../../platform/log/common/logService'; import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; import * as path from '../../../util/vs/base/common/path'; -import { isEqual } from '../../../util/vs/base/common/resources'; import { generateUuid } from '../../../util/vs/base/common/uuid'; import { IAgentSessionsWorkspace } from '../common/agentSessionsWorkspace'; import { IChatSessionMetadataStore } from '../common/chatSessionMetadataStore'; @@ -178,28 +177,13 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi return branch; } - getWorktreeProperties(sessionId: string): Promise; - getWorktreeProperties(folder: vscode.Uri): Promise; - async getWorktreeProperties(sessionIdOrFolder: string | vscode.Uri): Promise { - if (typeof sessionIdOrFolder === 'string') { - const properties = this._sessionWorktrees.get(sessionIdOrFolder); - if (properties !== undefined) { - return typeof properties === 'string' ? undefined : properties; - } - // Fall back to metadata store (file-based) - return this.metadataStore.getWorktreeProperties(sessionIdOrFolder); - } else { - for (const [_, value] of this._sessionWorktrees.entries()) { - if (typeof value === 'string') { - continue; - } - if (isEqual(vscode.Uri.file(value.worktreePath), sessionIdOrFolder)) { - return value; - } - } - // Fall back to metadata store (file-based) - return this.metadataStore.getWorktreeProperties(sessionIdOrFolder); + async getWorktreeProperties(sessionId: string): Promise { + const properties = this._sessionWorktrees.get(sessionId); + if (properties !== undefined) { + return typeof properties === 'string' ? undefined : properties; } + // Fall back to metadata store (file-based) + return this.metadataStore.getWorktreeProperties(sessionId); } async setWorktreeProperties(sessionId: string, properties: ChatSessionWorktreeProperties): Promise { @@ -397,18 +381,6 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi } } - async getSessionIdForWorktree(folder: vscode.Uri): Promise { - for (const [sessionId, value] of this._sessionWorktrees.entries()) { - if (typeof value === 'string') { - continue; - } - if (isEqual(vscode.Uri.file(value.worktreePath), folder)) { - return sessionId; - } - } - return this.metadataStore.getSessionIdForWorktree(folder); - } - async handleRequestCompleted(sessionId: string): Promise { const worktreeProperties = await this.getWorktreeProperties(sessionId); if (!worktreeProperties) { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index 3b678188f732d..08ed2b1fd1ffc 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -131,7 +131,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements private readonly controller: vscode.ChatSessionItemController; private readonly newSessions = new ResourceMap(); - constructor( @ICopilotCLISessionService private readonly sessionService: ICopilotCLISessionService, @IChatSessionWorktreeService private readonly copilotCLIWorktreeManagerService: IChatSessionWorktreeService, @@ -308,6 +307,10 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements this._register(this._workspaceService.onDidChangeWorkspaceFolders(refreshActiveInputState)); } + public getAssociatedSessions(folder: Uri): string[] { + return this._metadataStore.getSessionIdsForFolder(folder); + } + public async updateInputStateAfterFolderSelection(inputState: vscode.ChatSessionInputState, folderUri: vscode.Uri): Promise { await this._optionGroupBuilder.rebuildInputState(inputState, folderUri); } @@ -341,7 +344,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements let worktreeProperties = await raceCancellation(this.copilotCLIWorktreeManagerService.getWorktreeProperties(session.id), token); const workingDirectory = worktreeProperties?.worktreePath ? vscode.Uri.file(worktreeProperties.worktreePath) : session.workingDirectory; - if (token.isCancellationRequested) { return item; @@ -352,7 +354,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements // `buildChanges` runs `git diff` and is the slow leg of populating an item. Skip it on the // eager pass and let `resolveChatSessionItem` fill it in lazily for visible items. // But if computing changes is easy (cached or the like), then include them right away to avoid a second update pass. - if (options?.includeChanges || ((await this.canBuildChangesFast(session.id, worktreeProperties)))) { + if (options?.includeChanges || ((await this.hasCachedChanges(session.id, worktreeProperties)))) { const changes = await this.buildChanges(session.id, worktreeProperties, workingDirectory, token); if (token.isCancellationRequested) { return item; @@ -407,19 +409,15 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements return badge; } - private async canBuildChangesFast(sessionId: string, worktreeProperties: Awaited>): Promise { + private async hasCachedChanges(sessionId: string, worktreeProperties: Awaited>): Promise { if (!this.configurationService.getConfig(ConfigKey.Advanced.CLIChatLazyLoadSessionItem)) { return true; } - if (!worktreeProperties?.repositoryPath) { - return false; - } - const [trusted, hasCachedWorktreeChanges, hasCachedWorkspaceChanges] = await Promise.all([ - vscode.workspace.isResourceTrusted(vscode.Uri.file(worktreeProperties.repositoryPath)), + const [hasCachedWorktreeChanges, hasCachedWorkspaceChanges] = await Promise.all([ this.copilotCLIWorktreeManagerService.hasCachedChanges(sessionId), this._workspaceFolderService.hasCachedChanges(sessionId) ]); - return trusted && (hasCachedWorktreeChanges || hasCachedWorkspaceChanges); + return hasCachedWorktreeChanges || hasCachedWorkspaceChanges; } private async buildChanges( @@ -1700,21 +1698,19 @@ export function registerCLIChatCommands( logService.trace('[commitToWorktree] Commit successful'); // Clear the worktree changes cache so getWorktreeChanges() recomputes - const sessionId = await copilotCLIWorktreeManagerService.getSessionIdForWorktree(worktreeUri); - if (sessionId) { + const sessionIds = await contentProvider.getAssociatedSessions(worktreeUri); + await Promise.all(sessionIds.map(async sessionId => { const props = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); if (props) { await copilotCLIWorktreeManagerService.setWorktreeProperties(sessionId, { ...props, changes: undefined }); } else { logService.error('[commitToWorktree] No worktree properties found for session:', sessionId); } - } else { - logService.error('[commitToWorktree] No session found for worktree:', worktreeUri.toString()); - } + })); logService.trace('[commitToWorktree] Notifying sessions change'); - if (sessionId) { - await contentProvider.refreshSession({ reason: 'update', sessionId }); + if (sessionIds.length) { + await contentProvider.refreshSession({ reason: 'update', sessionIds }); } } catch (error) { const { stdout = '', stderr = '', gitErrorCode } = error as { stdout?: string; stderr?: string; gitErrorCode?: string }; diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index 62c6e2bb764c6..b9bec5c4577b8 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -171,7 +171,6 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc private readonly _onDidCommitChatSessionItem = this._register(new Emitter<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }>()); public readonly onDidCommitChatSessionItem: Event<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }> = this._onDidCommitChatSessionItem.event; - public resolveChatSessionItem?: (item: vscode.ChatSessionItem, token: vscode.CancellationToken) => Promise; constructor( @@ -217,6 +216,10 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc })); } + public getAssociatedSessions(folder: Uri): string[] { + return this.chatSessionMetadataStore.getSessionIdsForFolder(folder); + } + /** * We should remove this or move this to CopilotCLISessionService */ @@ -297,7 +300,7 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc // eager pass and let `resolveChatSessionItem` fill it in lazily for visible items. // But if computing changes is easy (cached or the like), then include them right away to avoid a second update pass. let changes: vscode.ChatSessionChangedFile[] | undefined; - if (!token.isCancellationRequested && (options?.includeChanges || (await this.canBuildChangesFast(session.id, worktreeProperties)))) { + if (!token.isCancellationRequested && (options?.includeChanges || (await this.hasCachedChanges(session.id, worktreeProperties)))) { changes = await this.buildChanges(session.id, worktreeProperties, workingDirectory, token); // We need to get an updated version of worktree properties here because when the @@ -406,19 +409,15 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc } satisfies vscode.ChatSessionItem; } - private async canBuildChangesFast(sessionId: string, worktreeProperties: Awaited>): Promise { + private async hasCachedChanges(sessionId: string, worktreeProperties: Awaited>): Promise { if (!this.configurationService.getConfig(ConfigKey.Advanced.CLIChatLazyLoadSessionItem)) { return true; } - if (!worktreeProperties?.repositoryPath) { - return false; - } - const [trusted, hasCachedWorktreeChanges, hasCachedWorkspaceChanges] = await Promise.all([ - vscode.workspace.isResourceTrusted(vscode.Uri.file(worktreeProperties.repositoryPath)), + const [hasCachedWorktreeChanges, hasCachedWorkspaceChanges] = await Promise.all([ this.worktreeManager.hasCachedChanges(sessionId), this.workspaceFolderService.hasCachedChanges(sessionId) ]); - return trusted && (hasCachedWorktreeChanges || hasCachedWorkspaceChanges); + return hasCachedWorktreeChanges || hasCachedWorkspaceChanges; } @@ -2727,17 +2726,15 @@ export function registerCLIChatCommands( logService.trace('[commitToWorktree] Commit successful'); // Clear the worktree changes cache so getWorktreeChanges() recomputes - const sessionId = await copilotCLIWorktreeManagerService.getSessionIdForWorktree(worktreeUri); - if (sessionId) { + const sessionIds = await copilotcliSessionItemProvider.getAssociatedSessions(worktreeUri); + await Promise.all(sessionIds.map(async sessionId => { const props = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); if (props) { await copilotCLIWorktreeManagerService.setWorktreeProperties(sessionId, { ...props, changes: undefined }); } else { logService.error('[commitToWorktree] No worktree properties found for session:', sessionId); } - } else { - logService.error('[commitToWorktree] No session found for worktree:', worktreeUri.toString()); - } + })); logService.trace('[commitToWorktree] Notifying sessions change'); copilotcliSessionItemProvider.notifySessionsChange(); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts index 6c1e857cf1c8e..3ada694c2529a 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts @@ -16,7 +16,7 @@ import { ResourceSet } from '../../../util/vs/base/common/map'; import { isEqual } from '../../../util/vs/base/common/resources'; import { createTimeout } from '../../inlineEdits/common/common'; import { IToolsService } from '../../tools/common/toolsService'; -import { RepositoryProperties } from '../common/chatSessionMetadataStore'; +import { RepositoryProperties, IChatSessionMetadataStore } from '../common/chatSessionMetadataStore'; import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService'; import { ChatSessionWorktreeProperties, IChatSessionWorktreeService } from '../common/chatSessionWorktreeService'; import { @@ -67,6 +67,7 @@ export abstract class FolderRepositoryManager extends Disposable implements IFol protected readonly workspaceService: IWorkspaceService, protected readonly logService: ILogService, protected readonly toolsService: IToolsService, + protected readonly metadataStore: IChatSessionMetadataStore ) { super(); @@ -211,7 +212,8 @@ export abstract class FolderRepositoryManager extends Disposable implements IFol // If we're in a single folder workspace, possible the user has opened the worktree folder directly. if (sessionId && folderUri) { - worktreeProperties = await this.worktreeService.getWorktreeProperties(folderUri); + const worktreeSessionIds = this.metadataStore.getWorktreeSessions(folderUri); + worktreeProperties = worktreeSessionIds.length ? await this.worktreeService.getWorktreeProperties(worktreeSessionIds[0]) : undefined; worktree = worktreeProperties ? vscode.Uri.file(worktreeProperties.worktreePath) : undefined; repositoryUri = worktreeProperties ? vscode.Uri.file(worktreeProperties.repositoryPath) : repositoryUri; } @@ -239,7 +241,8 @@ export abstract class FolderRepositoryManager extends Disposable implements IFol // If we're in a single folder workspace, possible the user has opened the worktree folder directly. if (sessionId && folderUri) { - worktreeProperties = await this.worktreeService.getWorktreeProperties(folderUri); + const worktreeSessionIds = this.metadataStore.getWorktreeSessions(folderUri); + worktreeProperties = worktreeSessionIds.length ? await this.worktreeService.getWorktreeProperties(worktreeSessionIds[0]) : undefined; worktree = worktreeProperties ? vscode.Uri.file(worktreeProperties.worktreePath) : undefined; repositoryUri = worktreeProperties ? vscode.Uri.file(worktreeProperties.repositoryPath) : repositoryUri; } @@ -855,9 +858,10 @@ export class CopilotCLIFolderRepositoryManager extends FolderRepositoryManager { @IWorkspaceService workspaceService: IWorkspaceService, @ILogService logService: ILogService, @IToolsService toolsService: IToolsService, - @IFileSystemService private readonly fileSystem: IFileSystemService + @IFileSystemService private readonly fileSystem: IFileSystemService, + @IChatSessionMetadataStore metadataStore: IChatSessionMetadataStore ) { - super(worktreeService, workspaceFolderService, gitService, workspaceService, logService, toolsService); + super(worktreeService, workspaceFolderService, gitService, workspaceService, logService, toolsService, metadataStore); } /** @@ -898,9 +902,10 @@ export class ClaudeFolderRepositoryManager extends FolderRepositoryManager { @ILogService logService: ILogService, @IToolsService toolsService: IToolsService, @IClaudeSessionStateService private readonly sessionStateService: IClaudeSessionStateService, - @IFileSystemService private readonly fileSystem: IFileSystemService + @IFileSystemService private readonly fileSystem: IFileSystemService, + @IChatSessionMetadataStore metadataStore: IChatSessionMetadataStore ) { - super(worktreeService, workspaceFolderService, gitService, workspaceService, logService, toolsService); + super(worktreeService, workspaceFolderService, gitService, workspaceService, logService, toolsService, metadataStore); } /** diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionMetadataStoreImpl.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionMetadataStoreImpl.spec.ts index 4df78020aeec5..bd928872a6fa0 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionMetadataStoreImpl.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionMetadataStoreImpl.spec.ts @@ -372,95 +372,8 @@ describe('ChatSessionMetadataStore', () => { store.dispose(); }); - it('should return worktree properties when looked up by folder Uri', async () => { - const props = makeWorktreeV1Props({ worktreePath: Uri.file('/repo/.worktrees/my-wt').fsPath }); - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({ - 'session-wt': { worktreeProperties: props }, - })); - - const store = await createStore(); - const wt = await store.getWorktreeProperties(Uri.file('/repo/.worktrees/my-wt')); - expect(wt).toBeDefined(); - expect(wt!.branchName).toBe(props.branchName); - store.dispose(); - }); - - it('should return undefined when folder Uri does not match any worktree', async () => { - const props = makeWorktreeV1Props({ worktreePath: Uri.file('/repo/.worktrees/wt-a').fsPath }); - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({ - 'session-wt': { worktreeProperties: props }, - })); - - const store = await createStore(); - const wt = await store.getWorktreeProperties(Uri.file('/repo/.worktrees/wt-b')); - expect(wt).toBeUndefined(); - store.dispose(); - }); - - it('should skip entries without worktreePath when looking up by folder Uri', async () => { - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({ - 'session-folder': { workspaceFolder: { folderPath: Uri.file('/workspace/a').fsPath, timestamp: 100 } }, - 'session-wt': { worktreeProperties: makeWorktreeV1Props({ worktreePath: Uri.file('/repo/.worktrees/wt').fsPath }) }, - })); - - const store = await createStore(); - const wt = await store.getWorktreeProperties(Uri.file('/repo/.worktrees/wt')); - expect(wt).toBeDefined(); - store.dispose(); - }); }); - // ────────────────────────────────────────────────────────────────────────── - // getSessionIdForWorktree - // ────────────────────────────────────────────────────────────────────────── - describe('getSessionIdForWorktree', () => { - it('should return session id when worktree folder matches', async () => { - const props = makeWorktreeV1Props({ worktreePath: Uri.file('/repo/.worktrees/my-wt').fsPath }); - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({ - 'session-abc': { worktreeProperties: props }, - })); - - const store = await createStore(); - const sessionId = await store.getSessionIdForWorktree(Uri.file('/repo/.worktrees/my-wt')); - expect(sessionId).toBe('session-abc'); - store.dispose(); - }); - - it('should return undefined when no worktree matches the folder', async () => { - const props = makeWorktreeV1Props({ worktreePath: Uri.file('/repo/.worktrees/wt-a').fsPath }); - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({ - 'session-abc': { worktreeProperties: props }, - })); - - const store = await createStore(); - const sessionId = await store.getSessionIdForWorktree(Uri.file('/some/other/path')); - expect(sessionId).toBeUndefined(); - store.dispose(); - }); - - it('should return undefined when cache has no worktree entries', async () => { - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({ - 'session-folder': { workspaceFolder: { folderPath: Uri.file('/a').fsPath, timestamp: 1 } }, - })); - - const store = await createStore(); - const sessionId = await store.getSessionIdForWorktree(Uri.file('/a')); - expect(sessionId).toBeUndefined(); - store.dispose(); - }); - - it('should find correct session among multiple worktree entries', async () => { - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({ - 'session-1': { worktreeProperties: makeWorktreeV1Props({ worktreePath: Uri.file('/repo/.worktrees/wt1').fsPath }) }, - 'session-2': { worktreeProperties: makeWorktreeV2Props({ worktreePath: Uri.file('/repo/.worktrees/wt2').fsPath }) }, - })); - - const store = await createStore(); - const sessionId = await store.getSessionIdForWorktree(Uri.file('/repo/.worktrees/wt2')); - expect(sessionId).toBe('session-2'); - store.dispose(); - }); - }); // ────────────────────────────────────────────────────────────────────────── // getSessionWorkspaceFolder @@ -1548,83 +1461,6 @@ describe('ChatSessionMetadataStore', () => { }); }); - describe('JSONL worktree index', () => { - const jsonlUri = () => Uri.file(jsonlPathHolder.get()); - - async function readJsonl(): Promise>> { - try { - const bytes = await mockFs.readFile(jsonlUri()); - const raw = new TextDecoder().decode(bytes); - return raw.split('\n').filter(Boolean).map(l => JSON.parse(l)); - } catch { - return []; - } - } - - it('appends one line per worktree session and reads it back via getSessionIdForWorktree', async () => { - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({})); - const store = await createStore(); - - const folder = Uri.file('/repo/.worktrees/wt-A'); - await store.storeWorktreeInfo('wt-session-A', makeWorktreeV1Props({ worktreePath: folder.fsPath })); - - const lines = await readJsonl(); - expect(lines).toHaveLength(1); - expect(lines[0]).toMatchObject({ id: 'wt-session-A', path: folder.fsPath }); - - // Lookup by folder works via the in-memory map. - expect(await store.getSessionIdForWorktree(folder)).toBe('wt-session-A'); - store.dispose(); - }); - - it('falls back to JSONL on disk for getSessionIdForWorktree when in-memory cache is cold', async () => { - // Pre-seed JSONL in mock fs before the store starts — simulates an entry written by another process. - const folder = Uri.file('/repo/.worktrees/wt-cold'); - mockFs.mockFile( - jsonlUri(), - JSON.stringify({ id: 'wt-session-cold', path: folder.fsPath, created: 100 }) + '\n', - ); - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({})); - - const store = await createStore(); - expect(await store.getSessionIdForWorktree(folder)).toBe('wt-session-cold'); - store.dispose(); - }); - - it('compacts duplicate JSONL lines for the same id on next rewrite', async () => { - // Two entries for the same id — last write wins, file should be rewritten. - const folder = Uri.file('/repo/.worktrees/dup'); - mockFs.mockFile( - jsonlUri(), - JSON.stringify({ id: 'dup-id', path: '/old/path', created: 1 }) + '\n' + - JSON.stringify({ id: 'dup-id', path: folder.fsPath, created: 2 }) + '\n', - ); - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({})); - - const store = await createStore(); - // Initialization should have detected the duplicate and rewritten the file. - const lines = await readJsonl(); - expect(lines).toHaveLength(1); - expect(lines[0]).toMatchObject({ id: 'dup-id', path: folder.fsPath }); - expect(await store.getSessionIdForWorktree(folder)).toBe('dup-id'); - store.dispose(); - }); - - it('removes the JSONL entry when a session is deleted', async () => { - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({})); - const store = await createStore(); - const folder = Uri.file('/repo/.worktrees/to-delete'); - await store.storeWorktreeInfo('to-delete', makeWorktreeV1Props({ worktreePath: folder.fsPath })); - expect(await readJsonl()).toHaveLength(1); - - await store.deleteSessionMetadata('to-delete'); - - expect(await readJsonl()).toHaveLength(0); - expect(await store.getSessionIdForWorktree(folder)).toBeUndefined(); - store.dispose(); - }); - }); - describe('top-N trim (MAX_BULK_STORAGE_ENTRIES = 1000)', () => { it('writes at most 1000 entries to the bulk file but keeps everything in memory', async () => { // Pre-seed a bulk file with 1100 entries with varying `modified` timestamps. @@ -1711,69 +1547,4 @@ describe('ChatSessionMetadataStore', () => { store.dispose(); }); }); - - describe('session-state directory scan', () => { - const sessionStateDir = Uri.file('/mock/session-state'); - - it('discovers worktree sessions from per-session files not in cache or JSONL', async () => { - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({})); - // Simulate a per-session file on disk that is NOT in the bulk cache. - const folder = Uri.file('/repo/.worktrees/discovered'); - await mockFs.createDirectory(Uri.joinPath(sessionStateDir, 'orphan-session')); - mockFs.mockFile( - sessionMetadataFileUri('orphan-session'), - JSON.stringify({ worktreeProperties: makeWorktreeV1Props({ worktreePath: folder.fsPath }) }), - ); - // readDirectory returns the session dir entries. - mockFs.mockDirectory(sessionStateDir, [['orphan-session', 2 /* Directory */]]); - - const store = await createStore(); - - expect(await store.getSessionIdForWorktree(folder)).toBe('orphan-session'); - store.dispose(); - }); - - it('skips session IDs already known from the bulk cache', async () => { - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({ - 'known-session': { workspaceFolder: { folderPath: Uri.file('/known').fsPath, timestamp: 1 } }, - })); - mockFs.mockDirectory(sessionStateDir, [['known-session', 2]]); - - const readSpy = vi.spyOn(mockFs, 'readFile'); - const store = await createStore(); - - // Per-session file for known-session should NOT have been read by the scan. - const scanReads = readSpy.mock.calls.filter( - c => c[0].toString().includes('/mock/session-state/known-session/vscode.metadata.json'), - ); - expect(scanReads).toHaveLength(0); - store.dispose(); - }); - - it('sets memento flag so the scan does not re-run on next startup', async () => { - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({})); - mockFs.mockDirectory(sessionStateDir, []); - - await createStore(); - expect(extensionContext.globalState.get('github.copilot.cli.events.jsonl.scaned')).toBe(true); - }); - - it('skips scan when memento flag is already set', async () => { - extensionContext.globalState.seed('github.copilot.cli.events.jsonl.scaned', true); - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({})); - // Even with a discoverable session, scan should be skipped. - await mockFs.createDirectory(Uri.joinPath(sessionStateDir, 'should-skip')); - mockFs.mockFile( - sessionMetadataFileUri('should-skip'), - JSON.stringify({ worktreeProperties: makeWorktreeV1Props() }), - ); - mockFs.mockDirectory(sessionStateDir, [['should-skip', 2]]); - - const store = await createStore(); - - expect(await store.getSessionIdForWorktree(Uri.file(makeWorktreeV1Props().worktreePath))).toBeUndefined(); - store.dispose(); - }); - }); - }); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts index fc58d58e11e5c..24f16b7055438 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts @@ -414,7 +414,8 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { workspaceService, logService, tools, - fileSystem + fileSystem, + new MockChatSessionMetadataStore() ); instantiationService = accessor.get(IInstantiationService); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/folderRepositoryManager.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/folderRepositoryManager.spec.ts index 1318a17f678ae..d691c78203a0b 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/folderRepositoryManager.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/folderRepositoryManager.spec.ts @@ -22,6 +22,7 @@ import { ChatSessionWorktreeFile, ChatSessionWorktreeProperties, IChatSessionWor import { IFolderRepositoryManager } from '../../common/folderRepositoryManager'; import { ICopilotCLISessionService } from '../../copilotcli/node/copilotcliSessionService'; import { ClaudeFolderRepositoryManager, CopilotCLIFolderRepositoryManager } from '../folderRepositoryManagerImpl'; +import { MockChatSessionMetadataStore } from '../../common/test/mockChatSessionMetadataStore'; import type { IClaudeSessionStateService } from '../../claude/common/claudeSessionStateService'; import type { ClaudeFolderInfo } from '../../claude/common/claudeFolderInfo'; @@ -317,7 +318,8 @@ describe('CopilotCLIFolderRepositoryManager', () => { workspaceService, logService, toolsService, - fileSystem + fileSystem, + new MockChatSessionMetadataStore() ); }); @@ -551,7 +553,8 @@ describe('CopilotCLIFolderRepositoryManager', () => { workspaceService, logService, toolsService, - new MockFileSystemService() + new MockFileSystemService(), + new MockChatSessionMetadataStore() ); manager.setNewSessionFolder(sessionId, folderUri); @@ -713,7 +716,8 @@ describe('CopilotCLIFolderRepositoryManager', () => { workspaceService, logService, toolsService, - new MockFileSystemService() + new MockFileSystemService(), + new MockChatSessionMetadataStore() ); const token = disposables.add(new CancellationTokenSource()).token; const stream = new MockChatResponseStream(); @@ -733,8 +737,8 @@ describe('CopilotCLIFolderRepositoryManager', () => { describe('worktree folder opened as workspace folder', () => { const mockToolInvocationToken = {} as vscode.ChatParticipantToolToken; - const worktreeFolderPath = '/repo-worktree'; - const originalRepoPath = '/original-repo'; + const worktreeFolderPath = vscode.Uri.file('/repo-worktree').fsPath; + const originalRepoPath = vscode.Uri.file('/original-repo').fsPath; const defaultWorktreeProps: ChatSessionWorktreeProperties = { autoCommit: true, baseCommit: 'abc123', @@ -745,6 +749,15 @@ describe('CopilotCLIFolderRepositoryManager', () => { }; describe('initializeFolderRepository', () => { + function createMetadataStoreWithWorktree(): MockChatSessionMetadataStore { + const store = new MockChatSessionMetadataStore(); + // Register a session whose worktree path matches worktreeFolderPath so that + // getWorktreeSessions(folderUri) returns a session ID that the worktreeService + // can resolve via getWorktreeProperties. + void store.storeWorktreeInfo(vscode.Uri.file(worktreeFolderPath).fsPath, defaultWorktreeProps); + return store; + } + it('skips worktree creation when single workspace folder is already a tracked worktree', async () => { workspaceService = new MockWorkspaceService([URI.file(worktreeFolderPath)]); gitService.setTestActiveRepository({ @@ -755,7 +768,8 @@ describe('CopilotCLIFolderRepositoryManager', () => { manager = new CopilotCLIFolderRepositoryManager( worktreeService, workspaceFolderService, sessionService, gitService, workspaceService, logService, toolsService, - new MockFileSystemService() + new MockFileSystemService(), + createMetadataStoreWithWorktree() ); const sessionId = 'untitled:wt-test-1'; @@ -773,6 +787,12 @@ describe('CopilotCLIFolderRepositoryManager', () => { it('skips worktree creation when explicitly selected folder is a tracked worktree', async () => { worktreeService.setTestWorktreeProperties(vscode.Uri.file(worktreeFolderPath).fsPath, defaultWorktreeProps); + manager = new CopilotCLIFolderRepositoryManager( + worktreeService, workspaceFolderService, sessionService, + gitService, workspaceService, logService, toolsService, + new MockFileSystemService(), + createMetadataStoreWithWorktree() + ); const sessionId = 'untitled:wt-test-2'; const token = disposables.add(new CancellationTokenSource()).token; @@ -801,7 +821,8 @@ describe('CopilotCLIFolderRepositoryManager', () => { manager = new CopilotCLIFolderRepositoryManager( worktreeService, workspaceFolderService, sessionService, gitService, workspaceService, logService, toolsService, - new MockFileSystemService() + new MockFileSystemService(), + createMetadataStoreWithWorktree() ); const sessionId = 'untitled:wt-test-3'; @@ -822,6 +843,12 @@ describe('CopilotCLIFolderRepositoryManager', () => { remotes: [] as string[], changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [{ path: 'other.ts' }], mergeChanges: [], untrackedChanges: [] } } as unknown as RepoContext); + manager = new CopilotCLIFolderRepositoryManager( + worktreeService, workspaceFolderService, sessionService, + gitService, workspaceService, logService, toolsService, + new MockFileSystemService(), + createMetadataStoreWithWorktree() + ); const sessionId = 'untitled:wt-test-4'; const token = disposables.add(new CancellationTokenSource()).token; @@ -844,6 +871,12 @@ describe('CopilotCLIFolderRepositoryManager', () => { kind: 'repository', remotes: [] as string[], } as RepoContext); + manager = new CopilotCLIFolderRepositoryManager( + worktreeService, workspaceFolderService, sessionService, + gitService, workspaceService, logService, toolsService, + new MockFileSystemService(), + createMetadataStoreWithWorktree() + ); const sessionId = 'untitled:wt-test-5'; const token = disposables.add(new CancellationTokenSource()).token; @@ -869,7 +902,8 @@ describe('CopilotCLIFolderRepositoryManager', () => { manager = new CopilotCLIFolderRepositoryManager( worktreeService, workspaceFolderService, sessionService, gitService, workspaceService, logService, toolsService, - new MockFileSystemService() + new MockFileSystemService(), + createMetadataStoreWithWorktree() ); const sessionId = 'untitled:wt-test-6'; @@ -895,7 +929,8 @@ describe('CopilotCLIFolderRepositoryManager', () => { manager = new CopilotCLIFolderRepositoryManager( worktreeService, workspaceFolderService, sessionService, gitService, workspaceService, logService, toolsService, - new MockFileSystemService() + new MockFileSystemService(), + new MockChatSessionMetadataStore() ); (worktreeService.createWorktree as unknown as ReturnType).mockResolvedValue({ @@ -1030,7 +1065,8 @@ describe('CopilotCLIFolderRepositoryManager', () => { workspaceService, logService, toolsService, - new MockFileSystemService() + new MockFileSystemService(), + new MockChatSessionMetadataStore() ); const sessionId = 'untitled:empty-test'; @@ -1099,7 +1135,8 @@ describe('ClaudeFolderRepositoryManager', () => { logService, toolsService, sessionStateService, - fileSystem + fileSystem, + new MockChatSessionMetadataStore() ); }); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/worktreeSessionIndex.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/worktreeSessionIndex.spec.ts deleted file mode 100644 index c397ac52a180b..0000000000000 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/worktreeSessionIndex.spec.ts +++ /dev/null @@ -1,190 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { Uri } from 'vscode'; -import { MockFileSystemService } from '../../../../platform/filesystem/node/test/mockFileSystemService'; -import { ILogService } from '../../../../platform/log/common/logService'; -import { mock } from '../../../../util/common/test/simpleMock'; -import { WorktreeSessionIndex } from '../worktreeSessionIndex'; - -class MockLogService extends mock() { - override trace = vi.fn(); - override info = vi.fn(); - override warn = vi.fn(); - override error = vi.fn(); - override debug = vi.fn(); -} - -const JSONL_PATH = '/mock/copilot-home/worktree.jsonl'; -const JSONL_URI = Uri.file(JSONL_PATH); - -describe('WorktreeSessionIndex', () => { - let mockFs: MockFileSystemService; - let logService: MockLogService; - - function createIndex(): WorktreeSessionIndex { - return new WorktreeSessionIndex(mockFs, logService, JSONL_PATH); - } - - afterEach(() => { - vi.restoreAllMocks(); - }); - - // In-memory tests don't need mockFs/logService, but the constructor requires them. - describe('in-memory operations', () => { - it('adds and retrieves an entry by session id', () => { - mockFs = new MockFileSystemService(); - logService = new MockLogService(); - const index = createIndex(); - index.addEntry({ id: 's1', path: '/a', created: 1 }); - - expect(index.getSessionEntry('s1')).toMatchObject({ id: 's1', path: '/a' }); - expect(index.has('s1')).toBe(true); - expect(index.has('s2')).toBe(false); - }); - - it('looks up session id by folder Uri', () => { - mockFs = new MockFileSystemService(); - logService = new MockLogService(); - const index = createIndex(); - index.addEntry({ id: 's1', path: '/a', created: 1 }); - - expect(index.getSessionIdForFolder(Uri.file('/a'))).toBe('s1'); - expect(index.getSessionIdForFolder(Uri.file('/b'))).toBeUndefined(); - }); - - it('deletes an entry and cleans up the folder mapping', () => { - mockFs = new MockFileSystemService(); - logService = new MockLogService(); - const index = createIndex(); - index.addEntry({ id: 's1', path: '/a', created: 1 }); - index.deleteEntry('s1'); - - expect(index.has('s1')).toBe(false); - expect(index.getSessionIdForFolder(Uri.file('/a'))).toBeUndefined(); - }); - - it('clear() removes everything', () => { - mockFs = new MockFileSystemService(); - logService = new MockLogService(); - const index = createIndex(); - index.addEntry({ id: 's1', path: '/a', created: 1 }); - index.addEntry({ id: 's2', path: '/b', created: 2 }); - index.clear(); - - expect(index.has('s1')).toBe(false); - expect(index.getEntries()).toHaveLength(0); - }); - - it('getEntries() returns all entries', () => { - mockFs = new MockFileSystemService(); - logService = new MockLogService(); - const index = createIndex(); - index.addEntry({ id: 's1', path: '/a', created: 1 }); - index.addEntry({ id: 's2', path: '/b', created: 2 }); - - const entries = index.getEntries(); - expect(entries).toHaveLength(2); - expect(entries.map(e => e.id).sort()).toEqual(['s1', 's2']); - }); - - it('updating an entry with a new path removes the old path mapping', () => { - mockFs = new MockFileSystemService(); - logService = new MockLogService(); - const index = createIndex(); - index.addEntry({ id: 's1', path: '/old', created: 1 }); - expect(index.getSessionIdForFolder(Uri.file('/old'))).toBe('s1'); - - index.addEntry({ id: 's1', path: '/new', created: 1 }); - expect(index.getSessionIdForFolder(Uri.file('/new'))).toBe('s1'); - expect(index.getSessionIdForFolder(Uri.file('/old'))).toBeUndefined(); - }); - }); - - describe('JSONL persistence', () => { - it('loadFromDisk populates the index from a JSONL file', async () => { - mockFs = new MockFileSystemService(); - logService = new MockLogService(); - mockFs.mockFile(JSONL_URI, - JSON.stringify({ id: 's1', path: '/a', created: 1 }) + '\n' + - JSON.stringify({ id: 's2', path: '/b', created: 2 }) + '\n', - ); - const index = createIndex(); - await index.loadFromDisk(); - - expect(index.has('s1')).toBe(true); - expect(index.has('s2')).toBe(true); - expect(index.size).toBe(2); - }); - - it('loadFromDisk returns rewriteNeeded for duplicates', async () => { - mockFs = new MockFileSystemService(); - logService = new MockLogService(); - mockFs.mockFile(JSONL_URI, - JSON.stringify({ id: 's1', path: '/a', created: 1 }) + '\n' + - JSON.stringify({ id: 's1', path: '/b', created: 2 }) + '\n', - ); - const index = createIndex(); - const { rewriteNeeded } = await index.loadFromDisk(); - - expect(rewriteNeeded).toBe(true); - expect(index.size).toBe(1); - }); - - it('writeToDisk writes all entries to the JSONL file', async () => { - mockFs = new MockFileSystemService(); - logService = new MockLogService(); - const index = createIndex(); - index.addEntry({ id: 's1', path: '/a', created: 1 }); - index.addEntry({ id: 's2', path: '/b', created: 2 }); - await index.writeToDisk(); - - const raw = new TextDecoder().decode(await mockFs.readFile(JSONL_URI)); - const lines = raw.split('\n').filter(Boolean); - expect(lines).toHaveLength(2); - }); - - it('appendBatchToDisk adds a single entry', async () => { - mockFs = new MockFileSystemService(); - logService = new MockLogService(); - const index = createIndex(); - await index.appendBatchToDisk([{ id: 's1', path: '/a', created: 1 }]); - - expect(index.has('s1')).toBe(true); - const raw = new TextDecoder().decode(await mockFs.readFile(JSONL_URI)); - expect(raw.split('\n').filter(Boolean)).toHaveLength(1); - }); - - it('appendBatchToDisk adds multiple entries in one write', async () => { - mockFs = new MockFileSystemService(); - logService = new MockLogService(); - const index = createIndex(); - await index.appendBatchToDisk([ - { id: 's1', path: '/a', created: 1 }, - { id: 's2', path: '/b', created: 2 }, - ]); - - expect(index.has('s1')).toBe(true); - expect(index.has('s2')).toBe(true); - const raw = new TextDecoder().decode(await mockFs.readFile(JSONL_URI)); - expect(raw.split('\n').filter(Boolean)).toHaveLength(2); - }); - - it('removeAndWriteToDisk removes the entry and rewrites', async () => { - mockFs = new MockFileSystemService(); - logService = new MockLogService(); - const index = createIndex(); - index.addEntry({ id: 's1', path: '/a', created: 1 }); - index.addEntry({ id: 's2', path: '/b', created: 2 }); - await index.removeAndWriteToDisk('s1'); - - expect(index.has('s1')).toBe(false); - const raw = new TextDecoder().decode(await mockFs.readFile(JSONL_URI)); - expect(raw.split('\n').filter(Boolean)).toHaveLength(1); - expect(raw).toContain('s2'); - }); - }); -}); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/worktreeSessionIndex.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/worktreeSessionIndex.ts deleted file mode 100644 index ca8586c639f8a..0000000000000 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/worktreeSessionIndex.ts +++ /dev/null @@ -1,221 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Uri } from 'vscode'; -import { createDirectoryIfNotExists, IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; -import { ILogService } from '../../../platform/log/common/logService'; -import { Sequencer } from '../../../util/vs/base/common/async'; -import { ResourceMap } from '../../../util/vs/base/common/map'; -import { dirname } from '../../../util/vs/base/common/resources'; -import { WorktreeSessionEntry } from '../common/chatSessionMetadataStore'; - -/** - * In-memory index that maps session ids to {@link WorktreeSessionEntry} and - * worktree folder URIs to session ids, with JSONL file persistence. - * - * When multiple sessions share the same folder, the first-registered session - * keeps the folder → session-id mapping. - * - * All file writes are serialized through an internal {@link Sequencer} so - * concurrent appends and rewrites cannot race. - */ -export class WorktreeSessionIndex { - /** Session id → entry. */ - private readonly _byId = new Map(); - /** Worktree folder URI → session id. Uses URI-aware comparison so path casing is handled correctly. */ - private readonly _byFolder = new ResourceMap(); - /** Serializes all JSONL file writes to prevent read-modify-write races. */ - private readonly _writeSequencer = new Sequencer(); - /** Timestamp of the last {@link loadFromDisk} call; used by {@link reloadIfStale}. */ - private _lastLoadAt = 0; - - constructor( - private readonly _fileSystemService: IFileSystemService, - private readonly _logService: ILogService, - private readonly _jsonlPath: string, - ) { } - - getSessionEntry(sessionId: string): WorktreeSessionEntry | undefined { - return this._byId.get(sessionId); - } - - getSessionIdForFolder(folder: Uri): string | undefined { - return this._byFolder.get(folder); - } - - has(sessionId: string): boolean { - return this._byId.has(sessionId); - } - - get size(): number { - return this._byId.size; - } - - getAllSessionIds(): string[] { - return Array.from(this._byId.keys()); - } - - /** - * Adds or updates an entry. When the same folder path is already mapped to - * a different session, the existing mapping is preserved. - */ - addEntry(entry: WorktreeSessionEntry): void { - const folderUri = Uri.file(entry.path); - - // If this session already has an entry with a different path, clean up - // the old folder → session-id mapping before recording the new one. - const previousEntry = this._byId.get(entry.id); - if (previousEntry && previousEntry.path !== entry.path) { - const prevUri = Uri.file(previousEntry.path); - if (this._byFolder.get(prevUri) === entry.id) { - this._byFolder.delete(prevUri); - } - } - - this._byId.set(entry.id, entry); - - const existingIdForFolder = this._byFolder.get(folderUri); - if (!existingIdForFolder) { - this._byFolder.set(folderUri, entry.id); - return; - } - if (existingIdForFolder === entry.id) { - return; - } - const existingEntry = this._byId.get(existingIdForFolder); - if (existingEntry) { - return; - } - this._byFolder.set(folderUri, entry.id); - } - - deleteEntry(sessionId: string): void { - const entry = this._byId.get(sessionId); - if (!entry) { - return; - } - this._byId.delete(sessionId); - const folderUri = Uri.file(entry.path); - if (this._byFolder.get(folderUri) === sessionId) { - this._byFolder.delete(folderUri); - for (const candidate of this._byId.values()) { - if (candidate.path === entry.path) { - this._byFolder.set(folderUri, candidate.id); - break; - } - } - } - } - - clear(): void { - this._byId.clear(); - this._byFolder.clear(); - } - - getEntries(): WorktreeSessionEntry[] { - return Array.from(this._byId.values()); - } - - /** - * Loads the JSONL worktree index from disk into the in-memory maps. - * Returns `rewriteNeeded` if the file contained malformed lines or - * duplicates that should be compacted via {@link writeToDisk}. - */ - async loadFromDisk(): Promise<{ rewriteNeeded: boolean }> { - let rewriteNeeded = false; - let raw: string; - try { - const bytes = await this._fileSystemService.readFile(Uri.file(this._jsonlPath)); - raw = new TextDecoder().decode(bytes); - } catch { - this._lastLoadAt = Date.now(); - return { rewriteNeeded: false }; - } - this.clear(); - for (const line of raw.split('\n')) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - try { - const entry = JSON.parse(trimmed) as WorktreeSessionEntry; - if (!entry?.id || !entry.path) { - rewriteNeeded = true; - continue; - } - if (this._byId.has(entry.id)) { - rewriteNeeded = true; - } - this.addEntry(entry); - } catch { - rewriteNeeded = true; - } - } - this._lastLoadAt = Date.now(); - return { rewriteNeeded }; - } - - /** Reloads from disk only if more than 1 second has passed since the last load. */ - async reloadIfStale(): Promise { - if (Date.now() - this._lastLoadAt < 1000) { - return; - } - await this.loadFromDisk(); - } - - /** Writes the entire in-memory index to the JSONL file, replacing its contents. */ - async writeToDisk(): Promise { - return this._writeSequencer.queue(async () => { - try { - const jsonlUri = Uri.file(this._jsonlPath); - await createDirectoryIfNotExists(this._fileSystemService, dirname(jsonlUri)); - const lines = this._byId.size > 0 - ? Array.from(this._byId.values()).map(e => JSON.stringify(e)).join('\n') + '\n' - : ''; - await this._fileSystemService.writeFile(jsonlUri, new TextEncoder().encode(lines)); - } catch (err) { - this._logService.error('[WorktreeSessionIndex] Failed to write JSONL: ', err); - } - }); - } - - /** Appends entries to the JSONL file and adds them to the in-memory index. */ - async appendBatchToDisk(entries: WorktreeSessionEntry[]): Promise { - if (entries.length === 0) { - return; - } - return this._writeSequencer.queue(async () => { - try { - const jsonlUri = Uri.file(this._jsonlPath); - await createDirectoryIfNotExists(this._fileSystemService, dirname(jsonlUri)); - let existing = ''; - try { - existing = new TextDecoder().decode(await this._fileSystemService.readFile(jsonlUri)); - } catch { - // File doesn't exist yet. - } - const suffix = entries.map(e => JSON.stringify(e)).join('\n') + '\n'; - await this._fileSystemService.writeFile( - jsonlUri, - new TextEncoder().encode(existing + suffix), - ); - for (const entry of entries) { - this.addEntry(entry); - } - } catch (err) { - this._logService.error('[WorktreeSessionIndex] Failed to bulk-append entries: ', err); - } - }); - } - - /** Removes an entry from the in-memory index and rewrites the JSONL file. */ - async removeAndWriteToDisk(sessionId: string): Promise { - if (!this._byId.has(sessionId)) { - return; - } - this.deleteEntry(sessionId); - await this.writeToDisk(); - } -} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index f28419d86b9ac..b8686c5014611 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -546,19 +546,20 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode private readonly _resolvedResources = new ResourceSet(); observeSession(resource: URI): IObservable { + // Trigger resolve if not yet resolved for this resource (or if + // the guard was cleared after a provider refresh). This is + // separated from the observable cache so that re-calls after a + // refresh re-trigger the resolve RPC even though the observable + // already exists. + if (!this._resolvedResources.has(resource)) { + this._resolvedResources.add(resource); + const sessionType = getChatSessionType(resource); + this.chatSessionsService.resolveChatSessionItem(sessionType, resource, CancellationToken.None) + .catch(error => this.logger.logIfTrace(`observeSession: resolve failed for ${resource.toString()}: ${error instanceof Error ? error.message : String(error)}`)); + } + let observable = this._sessionObservables.get(resource); if (!observable) { - // Lazily trigger a resolve for this resource so consumers reading - // lazy properties (e.g. `changes`) get fresh data without needing - // to wait for a tree row to scroll into view. The chat sessions - // service deduplicates in-flight resolves by resource. - if (!this._resolvedResources.has(resource)) { - this._resolvedResources.add(resource); - const sessionType = getChatSessionType(resource); - this.chatSessionsService.resolveChatSessionItem(sessionType, resource, CancellationToken.None) - .catch(error => this.logger.logIfTrace(`observeSession: resolve failed for ${resource.toString()}: ${error instanceof Error ? error.message : String(error)}`)); - } - this._changedSignal ??= observableSignalFromEvent('agentSessionsChanged', this.onDidChangeSessions); const signal = this._changedSignal; observable = derived(reader => { @@ -610,6 +611,22 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode private async doResolveProvider(provider: string, options: { refreshProvider: boolean }, token: CancellationToken): Promise { if (options.refreshProvider) { await this.chatSessionsService.refreshChatSessionItems([provider], token); + + // Clear the resolve-once guard for sessions belonging to this + // provider and re-trigger resolve for any that were previously + // observed. This is necessary because the refresh returns items + // with lazy properties (e.g. changes: undefined) that need a + // fresh resolve RPC. Re-calling observeSession() for resources + // already in _sessionObservables is cheap (the observable is + // cached) and only fires the RPC side-effect. + for (const resource of [...this._resolvedResources]) { + if (getChatSessionType(resource) === provider) { + this._resolvedResources.delete(resource); + if (this._sessionObservables.has(resource)) { + this.observeSession(resource); + } + } + } } const mapSessionContributionToType = new Map(); From 34aaaae3d054d1295e05e59141704fb92a9bd496 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Thu, 23 Apr 2026 16:56:31 +0200 Subject: [PATCH 26/32] allow multiple slash commands of the same name but for different session types (#312134) * allow multiple slash commands of the same name but for different session types Co-authored-by: Copilot * address review comments --------- Co-authored-by: Copilot --- .../contrib/chat/browser/chatSlashCommands.ts | 2 +- .../contrib/chat/browser/widget/chatWidget.ts | 2 +- .../common/chatService/chatServiceImpl.ts | 2 +- .../common/participants/chatSlashCommands.ts | 61 +++++++++++--- .../common/chatService/chatService.test.ts | 81 +++++++++++++++++++ 5 files changed, 132 insertions(+), 16 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index c3da164372ac0..36daf21cd7f8c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -369,7 +369,7 @@ export class ChatSessionOptionSlashCommandsContribution extends Disposable { this.logService.warn(`[ChatSessionOptionSlashCommands] Skipping duplicate slash command '${name}' contributed by session type '${chatSessionType}'.`); continue; } - if (this.slashCommandService.hasCommand(name)) { + if (this.slashCommandService.hasCommand(name, chatSessionType)) { this.logService.warn(`[ChatSessionOptionSlashCommands] Slash command '${name}' contributed by session type '${chatSessionType}' is already registered; skipping.`); continue; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index ea9da97bf924c..c10c8d9bc7170 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -1899,7 +1899,7 @@ export class ChatWidget extends Disposable implements IChatWidget { if (e.followup.subCommand) { msg += `${chatSubcommandLeader}${e.followup.subCommand} `; } - } else if (!e.followup.agentId && e.followup.subCommand && this.chatSlashCommandService.hasCommand(e.followup.subCommand)) { + } else if (!e.followup.agentId && e.followup.subCommand && this.chatSlashCommandService.hasCommand(e.followup.subCommand, getChatSessionType(this.viewModel.model.sessionResource))) { msg = `${chatSubcommandLeader}${e.followup.subCommand} `; } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 0b84592f6e78e..f7f2ad013775a 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -1342,7 +1342,7 @@ export class ChatService extends Disposable implements IChatService { const agentResult = await this.chatAgentService.invokeAgent(agent.id, requestProps, progressCallback, history, token); rawResult = agentResult; agentOrCommandFollowups = this.chatAgentService.getFollowups(agent.id, requestProps, agentResult, history, followupsCancelToken); - } else if (commandPart && this.chatSlashCommandService.hasCommand(commandPart.slashCommand.command)) { + } else if (commandPart && this.chatSlashCommandService.hasCommand(commandPart.slashCommand.command, getChatSessionType(model.sessionResource))) { if (commandPart.slashCommand.silent !== true) { request = model.addRequest(parsedRequest, { variables: [] }, attempt, options?.modeInfo); completeResponseCreated(); diff --git a/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts index 5d6d7cde20fcb..a0f7a798d15ec 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts @@ -14,6 +14,8 @@ import { IChatFollowup, IChatProgress, IChatResponseProgressFileTreeData, IChatS import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; import { ChatAgentLocation, ChatModeKind } from '../constants.js'; import { URI } from '../../../../../base/common/uri.js'; +import { getChatSessionType } from '../model/chatUri.js'; +import { matchesSessionType } from '../promptSyntax/service/promptsService.js'; //#region slash service, commands etc @@ -63,16 +65,16 @@ export interface IChatSlashCommandService { registerSlashCommand(data: IChatSlashData, command: IChatSlashCallback): IDisposable; executeCommand(id: string, prompt: string, progress: IProgress, history: IChatMessage[], location: ChatAgentLocation, sessionResource: URI, token: CancellationToken, options?: IChatSendRequestOptions): Promise<{ followUp: IChatFollowup[] } | void>; getCommands(location: ChatAgentLocation, mode: ChatModeKind): Array; - hasCommand(id: string): boolean; + hasCommand(id: string, sessionType: string): boolean; } -type Tuple = { data: IChatSlashData; command?: IChatSlashCallback }; +type RegisteredSlashCommand = { data: IChatSlashData; command?: IChatSlashCallback }; export class ChatSlashCommandService extends Disposable implements IChatSlashCommandService { declare _serviceBrand: undefined; - private readonly _commands = new Map(); + private readonly _commands = new Map(); private readonly _onDidChangeCommands = this._register(new Emitter()); readonly onDidChangeCommands: Event = this._onDidChangeCommands.event; @@ -86,35 +88,68 @@ export class ChatSlashCommandService extends Disposable implements IChatSlashCom this._commands.clear(); } + private getSessionScopedCommands(id: string): RegisteredSlashCommand[] { + return this._commands.get(id) ?? []; + } + + private commandsOverlap(dataA: IChatSlashData, dataB: IChatSlashData): boolean { + if (dataA.sessionTypes === undefined || dataB.sessionTypes === undefined) { + return true; + } + + return dataA.sessionTypes.some(sessionType => dataB.sessionTypes?.includes(sessionType)); + } + + private getCommand(id: string, sessionType: string | undefined): RegisteredSlashCommand | undefined { + return this.getSessionScopedCommands(id).find(candidate => matchesSessionType(candidate.data.sessionTypes, sessionType)); + } + registerSlashCommand(data: IChatSlashData, command: IChatSlashCallback): IDisposable { - if (this._commands.has(data.command)) { - throw new Error(`Already registered a command with id ${data.command}}`); + const commandsForId = this.getSessionScopedCommands(data.command); + if (commandsForId.some(candidate => this.commandsOverlap(candidate.data, data))) { + throw new Error(`Already registered a command with id ${data.command}`); } - this._commands.set(data.command, { data, command }); + const entry = { data, command }; + commandsForId.push(entry); + this._commands.set(data.command, commandsForId); this._onDidChangeCommands.fire(); return toDisposable(() => { - if (this._commands.delete(data.command)) { - this._onDidChangeCommands.fire(); + const commandsForId = this._commands.get(data.command); + if (!commandsForId) { + return; + } + + const entryIndex = commandsForId.indexOf(entry); + if (entryIndex === -1) { + return; } + + commandsForId.splice(entryIndex, 1); + if (commandsForId.length === 0) { + this._commands.delete(data.command); + } + + this._onDidChangeCommands.fire(); }); } getCommands(location: ChatAgentLocation, mode: ChatModeKind): Array { return Array - .from(this._commands.values(), v => v.data) + .from(this._commands.values()) + .flatMap(commands => commands.map(v => v.data)) .filter(c => c.locations.includes(location) && (!c.modes || c.modes.includes(mode))); } - hasCommand(id: string): boolean { - return this._commands.has(id); + hasCommand(id: string, sessionType: string): boolean { + return !!this.getCommand(id, sessionType); } async executeCommand(id: string, prompt: string, progress: IProgress, history: IChatMessage[], location: ChatAgentLocation, sessionResource: URI, token: CancellationToken, options?: IChatSendRequestOptions): Promise<{ followUp: IChatFollowup[] } | void> { - const data = this._commands.get(id); + const data = this.getCommand(id, getChatSessionType(sessionResource)); if (!data) { - throw new Error('No command with id ${id} NOT registered'); + throw new Error(`No command with id ${id} NOT registered`); } if (!data.command) { await this._extensionService.activateByEvent(`onSlash:${id}`); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index f8fc406fd5cf5..9e8a7c6125a4e 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -47,6 +47,7 @@ import { ChatService } from '../../../common/chatService/chatServiceImpl.js'; import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { ChatEditingSessionState, IChatEditingService, IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { ChatModel, IChatModel, ISerializableChatData } from '../../../common/model/chatModel.js'; +import { LocalChatSessionUri } from '../../../common/model/chatUri.js'; import { ChatAgentService, IChatAgent, IChatAgentData, IChatAgentImplementation, IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ChatSlashCommandService, IChatSlashCommandService } from '../../../common/participants/chatSlashCommands.js'; import { IConfiguredHooksInfo, IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; @@ -228,6 +229,86 @@ suite('ChatService', () => { }); ensureNoDisposablesAreLeakedInTestSuite(); + test('slash commands can share ids across non-overlapping session types', async () => { + const slashCommandService = testDisposables.add(instantiationService.createInstance(ChatSlashCommandService)); + const executions: string[] = []; + const progress = { report: (_progress: IChatProgress) => { } }; + + testDisposables.add(slashCommandService.registerSlashCommand({ + command: 'switch', + detail: 'Local switch', + locations: [ChatAgentLocation.Chat], + sessionTypes: ['local'], + }, async () => { + executions.push('local'); + })); + + testDisposables.add(slashCommandService.registerSlashCommand({ + command: 'switch', + detail: 'Remote switch', + locations: [ChatAgentLocation.Chat], + sessionTypes: ['remote'], + }, async () => { + executions.push('remote'); + })); + + assert.strictEqual(slashCommandService.hasCommand('switch', 'local'), true); + assert.strictEqual(slashCommandService.hasCommand('switch', 'remote'), true); + assert.strictEqual(slashCommandService.hasCommand('switch', 'other'), false); + + await slashCommandService.executeCommand('switch', '', progress, [], ChatAgentLocation.Chat, LocalChatSessionUri.forSession('local-session'), CancellationToken.None); + await slashCommandService.executeCommand('switch', '', progress, [], ChatAgentLocation.Chat, URI.from({ scheme: 'remote', path: '/session' }), CancellationToken.None); + + assert.deepStrictEqual(executions, ['local', 'remote']); + }); + + test('slash commands reject overlapping session types for the same id', () => { + const slashCommandService = testDisposables.add(instantiationService.createInstance(ChatSlashCommandService)); + const command = async () => undefined; + + testDisposables.add(slashCommandService.registerSlashCommand({ + command: 'switch', + detail: 'Local switch', + locations: [ChatAgentLocation.Chat], + sessionTypes: ['local', 'remote'], + }, command)); + + assert.throws(() => slashCommandService.registerSlashCommand({ + command: 'switch', + detail: 'Remote switch', + locations: [ChatAgentLocation.Chat], + sessionTypes: ['remote', 'other'], + }, command)); + }); + + test('slash commands without session types apply to all session types', async () => { + const slashCommandService = testDisposables.add(instantiationService.createInstance(ChatSlashCommandService)); + const executions: string[] = []; + const progress = { report: (_progress: IChatProgress) => { } }; + + testDisposables.add(slashCommandService.registerSlashCommand({ + command: 'switch', + detail: 'All sessions switch', + locations: [ChatAgentLocation.Chat], + }, async () => { + executions.push('all'); + })); + + assert.strictEqual(slashCommandService.hasCommand('switch', 'local'), true); + assert.strictEqual(slashCommandService.hasCommand('switch', 'remote'), true); + + await slashCommandService.executeCommand('switch', '', progress, [], ChatAgentLocation.Chat, LocalChatSessionUri.forSession('local-session'), CancellationToken.None); + await slashCommandService.executeCommand('switch', '', progress, [], ChatAgentLocation.Chat, URI.from({ scheme: 'remote', path: '/session' }), CancellationToken.None); + + assert.deepStrictEqual(executions, ['all', 'all']); + assert.throws(() => slashCommandService.registerSlashCommand({ + command: 'switch', + detail: 'Remote switch', + locations: [ChatAgentLocation.Chat], + sessionTypes: ['remote'], + }, async () => undefined)); + }); + test('retrieveSession', async () => { const testService = createChatService(); // Don't add refs to testDisposables so we can control disposal From f84260a50800020786b2b8c9ce4d2f054e300edb Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:57:29 +0000 Subject: [PATCH 27/32] Engineering - update product build schedule to add 2 additional builds but continue to release only the builds at 5:00 UTC and 17:00 UTC (#312139) --- build/azure-pipelines/product-build.yml | 14 ++++++++++++-- build/azure-pipelines/product-publish.yml | 2 ++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/build/azure-pipelines/product-build.yml b/build/azure-pipelines/product-build.yml index 62a263442d8d6..4f196ad514d02 100644 --- a/build/azure-pipelines/product-build.yml +++ b/build/azure-pipelines/product-build.yml @@ -2,12 +2,22 @@ pr: none schedules: - cron: "0 5 * * Mon-Fri" - displayName: Mon-Fri at 7:00 + displayName: Mon-Fri at 5:00 UTC (build, publish and release) + branches: + include: + - main + - cron: "0 11 * * Mon-Fri" + displayName: Mon-Fri at 11:00 UTC (build, and publish) branches: include: - main - cron: "0 17 * * Mon-Fri" - displayName: Mon-Fri at 19:00 + displayName: Mon-Fri at 17:00 UTC (build, publish, and release) + branches: + include: + - main + - cron: "0 23 * * Mon-Fri" + displayName: Mon-Fri at 23:00 UTC (build, and publish) branches: include: - main diff --git a/build/azure-pipelines/product-publish.yml b/build/azure-pipelines/product-publish.yml index 1f124e2f8718b..8376b2a4d9d2a 100644 --- a/build/azure-pipelines/product-publish.yml +++ b/build/azure-pipelines/product-publish.yml @@ -116,6 +116,8 @@ jobs: - ${{ if and(in(parameters.VSCODE_QUALITY, 'insider', 'exploration'), eq(parameters.VSCODE_SCHEDULEDBUILD, true)) }}: - script: node build/azure-pipelines/common/releaseBuild.ts + # Only release on the 05:00 and 17:00 UTC schedules (skip the 11:00 and 23:00 UTC schedules) + condition: and(succeeded(), or(eq(format('{0:HH}', pipeline.startTime), '05'), eq(format('{0:HH}', pipeline.startTime), '17'))) env: PUBLISH_AUTH_TOKENS: "$(PUBLISH_AUTH_TOKENS)" displayName: Release build From fd6a74635f34bb9ff2d8e54a8dfc85ed54de1dd9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 08:30:49 -0700 Subject: [PATCH 28/32] Bump actions/cache from 4 to 5 (#312122) Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/chat-perf.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/chat-perf.yml b/.github/workflows/chat-perf.yml index 52e7db5c73d8b..26744376083e5 100644 --- a/.github/workflows/chat-perf.yml +++ b/.github/workflows/chat-perf.yml @@ -130,7 +130,7 @@ jobs: run: node build/lib/preLaunch.ts - name: Cache Electron - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: ~/.cache/electron key: electron-${{ runner.os }}-${{ hashFiles('.nvmrc', 'package.json') }} @@ -139,7 +139,7 @@ jobs: run: npx playwright install chromium - name: Cache Playwright - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('package.json') }} @@ -220,7 +220,7 @@ jobs: name: build-output - name: Restore Electron cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: ~/.cache/electron key: electron-${{ runner.os }}-${{ hashFiles('.nvmrc', 'package.json') }} @@ -229,7 +229,7 @@ jobs: run: node build/lib/preLaunch.ts - name: Restore Playwright cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('package.json') }} @@ -393,7 +393,7 @@ jobs: name: build-output - name: Restore Electron cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: ~/.cache/electron key: electron-${{ runner.os }}-${{ hashFiles('.nvmrc', 'package.json') }} @@ -402,7 +402,7 @@ jobs: run: node build/lib/preLaunch.ts - name: Restore Playwright cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('package.json') }} From 19d4617ad02271bfd8d67584c0fe60427a8fc9b0 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 23 Apr 2026 08:45:16 -0700 Subject: [PATCH 29/32] chat: allow reading vscode-chat-response-resource resources in chat (#312143) Closes #312042 --- extensions/copilot/src/extension/tools/node/toolUtils.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/extensions/copilot/src/extension/tools/node/toolUtils.ts b/extensions/copilot/src/extension/tools/node/toolUtils.ts index e09a4e37cb607..2c8a0cb59df02 100644 --- a/extensions/copilot/src/extension/tools/node/toolUtils.ts +++ b/extensions/copilot/src/extension/tools/node/toolUtils.ts @@ -199,6 +199,9 @@ export async function assertFileOkForTool(accessor: ServicesAccessor, uri: URI, if (sessionTranscriptService.isTranscriptUri(normalizedUri)) { return; } + if (normalizedUri.scheme === 'vscode-chat-response-resource') { + return; + } if (await isExternalInstructionsFile(normalizedUri, customInstructionsService, buildPromptContext)) { return; } From eab5eccc856b917b79eb38a133c174f75bd764c1 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 23 Apr 2026 17:45:42 +0200 Subject: [PATCH 30/32] SSO between Apps in Windows (#312144) * Agents: share keybindings, prompts and mcp from VS Code app * feedback * get shared secret from shared storage Co-authored-by: Copilot * fix using key Co-authored-by: Copilot * listen event changes Co-authored-by: Copilot --------- Co-authored-by: Copilot --- src/vs/platform/secrets/common/secrets.ts | 29 +++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/vs/platform/secrets/common/secrets.ts b/src/vs/platform/secrets/common/secrets.ts index b296a0439641e..d73ad20bdd57c 100644 --- a/src/vs/platform/secrets/common/secrets.ts +++ b/src/vs/platform/secrets/common/secrets.ts @@ -6,11 +6,12 @@ import { SequencerByKey } from '../../../base/common/async.js'; import { IEncryptionService } from '../../encryption/common/encryptionService.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; -import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } from '../../storage/common/storage.js'; +import { IStorageService, IStorageValueChangeEvent, InMemoryStorageService, StorageScope, StorageTarget } from '../../storage/common/storage.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { ILogService } from '../../log/common/log.js'; import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { Lazy } from '../../../base/common/lazy.js'; +import { isWindows } from '../../../base/common/platform.js'; /** * The storage key prefix used for all secrets. @@ -138,7 +139,7 @@ export class BaseSecretStorageService extends Disposable implements ISecretStora try { return await readEncryptedSecret( key, - (fullKey) => storageService.get(fullKey, StorageScope.APPLICATION), + (fullKey) => this.getValueFromStorage(key, fullKey, storageService), // If the storage service is in-memory, we don't need to decrypt this._type === 'in-memory' ? (v) => Promise.resolve(v) : (v) => this._encryptionService.decrypt(v), this._logService, @@ -159,7 +160,7 @@ export class BaseSecretStorageService extends Disposable implements ISecretStora await writeEncryptedSecret( key, value, - (fullKey, encrypted) => storageService.store(fullKey, encrypted, StorageScope.APPLICATION, StorageTarget.MACHINE), + (fullKey, encrypted) => this.setValueInStorage(key, fullKey, encrypted, storageService), // If the storage service is in-memory, we don't need to encrypt this._type === 'in-memory' ? (v) => Promise.resolve(v) : (v) => this._encryptionService.encrypt(v), this._logService, @@ -192,6 +193,23 @@ export class BaseSecretStorageService extends Disposable implements ISecretStora }); } + private getValueFromStorage(key: string, fullKey: string, storageService: IStorageService): string | undefined { + if (isWindows && CROSS_APP_SHARED_SECRET_KEYS.includes(key)) { + this._logService.trace(`[SecretStorageService] Fetching value for cross-app shared secret: ${fullKey}`); + return storageService.get(fullKey, StorageScope.APPLICATION_SHARED); + } + return storageService.get(fullKey, StorageScope.APPLICATION); + } + + private setValueInStorage(key: string, fullKey: string, value: string, storageService: IStorageService): void { + if (isWindows && CROSS_APP_SHARED_SECRET_KEYS.includes(key)) { + this._logService.trace(`[SecretStorageService] Setting value for cross-app shared secret: ${fullKey}`); + storageService.store(fullKey, value, StorageScope.APPLICATION_SHARED, StorageTarget.MACHINE); + return; + } + storageService.store(fullKey, value, StorageScope.APPLICATION, StorageTarget.MACHINE); + } + private async initialize(): Promise { let storageService; if (!this._useInMemoryStorage && await this._encryptionService.isEncryptionAvailable()) { @@ -209,7 +227,10 @@ export class BaseSecretStorageService extends Disposable implements ISecretStora } this._onDidChangeValueDisposable.clear(); - this._onDidChangeValueDisposable.add(storageService.onDidChangeValue(StorageScope.APPLICATION, undefined, this._onDidChangeValueDisposable)(e => { + this._onDidChangeValueDisposable.add(Event.any( + storageService.onDidChangeValue(StorageScope.APPLICATION, undefined, this._onDidChangeValueDisposable), + storageService.onDidChangeValue(StorageScope.APPLICATION_SHARED, undefined, this._onDidChangeValueDisposable), + )(e => { this.onDidChangeValue(e.key); })); return storageService; From d820bee913c2e8d1afd0d39f2e488e931f9cb799 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:46:12 +0000 Subject: [PATCH 31/32] Agents - adjust auxiliarybar/editor default size (#312149) --- src/vs/sessions/browser/parts/auxiliaryBarPart.ts | 2 +- src/vs/sessions/browser/workbench.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts index 37798abc20b79..fe0b787bde16e 100644 --- a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts +++ b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts @@ -90,7 +90,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { return undefined; } - return Math.max(width, 380); + return Math.max(width, 340); } readonly priority = LayoutPriority.Low; diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index a6d10ff5f50aa..e012ecf4941cc 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -892,8 +892,8 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // Default sizes const sideBarSize = 300; - const editorSize = 650; - const auxiliaryBarSize = 380; + const editorSize = 600; + const auxiliaryBarSize = 340; const panelSize = 300; const titleBarHeight = this.titleBarPartView?.minimumHeight ?? 30; From 8bcec198e114e88179a40f220dbe26730b2c97a3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:10:36 +0000 Subject: [PATCH 32/32] Bump fast-xml-parser from 5.5.7 to 5.7.1 in /extensions/copilot (#312023) Bumps [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) from 5.5.7 to 5.7.1. - [Release notes](https://github.com/NaturalIntelligence/fast-xml-parser/releases) - [Changelog](https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/CHANGELOG.md) - [Commits](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.7...v5.7.1) --- updated-dependencies: - dependency-name: fast-xml-parser dependency-version: 5.7.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> --- extensions/copilot/package-lock.json | 44 ++++++++++++++++++---------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index e7b122547ef81..25b714b949869 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -4271,6 +4271,19 @@ "integrity": "sha512-JPQZWPKQJjj7kAftdEZL0XDFfbMgXCGiUAZe0d7EhLC3QlXTlZdSckGqqRIQ2QNl0VTEZyZUvRBw6Ednw089Fw==", "license": "MIT" }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -12648,9 +12661,9 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-builder": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", - "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", + "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", "dev": true, "funding": [ { @@ -12664,9 +12677,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.5.7", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.7.tgz", - "integrity": "sha512-LteOsISQ2GEiDHZch6L9hB0+MLoYVLToR7xotrzU0opCICBkxOPgHAy1HxAvtxfJNXDJpgAsQN30mkrfpO2Prg==", + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.1.tgz", + "integrity": "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA==", "dev": true, "funding": [ { @@ -12676,9 +12689,10 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.1.4", - "path-expression-matcher": "^1.1.3", - "strnum": "^2.2.0" + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.5", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" @@ -18026,9 +18040,9 @@ } }, "node_modules/path-expression-matcher": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", - "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", "dev": true, "funding": [ { @@ -20564,9 +20578,9 @@ "license": "MIT" }, "node_modules/strnum": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", - "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", + "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", "dev": true, "funding": [ {