From ef3425fc27d4829ff8752b93285607b215ab67dd Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 02:50:23 +0100 Subject: [PATCH 001/165] Workbench: dock the chat part beside the view that a feature names A feature that registers the chat part can now name a view to dock it beside: IWorkbenchLayoutService.setChatPartAnchor({ viewId }). The chat part stays immediately beside the editor, on the side of the editor where that view's container is. - Primary side bar: the side bar's side. Secondary side bar: the other side. Panel on the left or right: the panel's side, between the panel and the editor. Panel at the top or bottom, an unknown view, or no anchor: the primary side bar's side, as before. - It follows as the view or its container moves, as views register, and as the side bar or panel position changes, keeping its width whether it is shown or hidden. - getChatPartPosition() tells the floating outer edges which side the chat part is on. Without an anchor, the layout is the same as before. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/browser/workbench.ts | 8 ++ src/vs/workbench/browser/layout.ts | 70 +++++++++++- .../services/layout/browser/layoutService.ts | 42 ++++--- .../layout/test/browser/layoutService.test.ts | 6 +- src/vs/workbench/test/browser/layout.test.ts | 106 +++++++++++++++++- .../browser/parts/chat/chatHostPart.test.ts | 3 + .../test/browser/workbenchTestServices.ts | 2 + 7 files changed, 208 insertions(+), 29 deletions(-) diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index c26009fed5f143..b501a82e24f435 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -3019,6 +3019,14 @@ export abstract class Workbench extends Disposable implements IAgentWorkbenchLay return Position.LEFT; // Always left in this layout } + setChatPartAnchor(): void { + // This layout has no chat part + } + + getChatPartPosition(): Position.LEFT { + return Position.LEFT; + } + getDetailsPosition(): DetailsPosition { return this._detailsPosition === 'left' || this._detailsPosition === 'top' || this._detailsPosition === 'bottom' ? this._detailsPosition : 'right'; } diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index 2e7e9e7cb31ed7..3424609d74b77a 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -42,7 +42,8 @@ import { isHighContrast } from '../../platform/theme/common/theme.js'; import { WINDOW_ACTIVE_BORDER, WINDOW_INACTIVE_BORDER } from '../common/theme.js'; import { LineNumbersType } from '../../editor/common/config/editorOptions.js'; import { URI } from '../../base/common/uri.js'; -import { IViewDescriptorService, ViewContainerLocation } from '../common/views.js'; +import { IViewDescriptorService, IViewsRegistry, ViewContainerLocation, Extensions as ViewExtensions } from '../common/views.js'; +import { Registry } from '../../platform/registry/common/platform.js'; import { DiffEditorInput } from '../common/editor/diffEditorInput.js'; import { mark } from '../../base/common/performance.js'; import { IExtensionService } from '../services/extensions/common/extensions.js'; @@ -325,6 +326,10 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi private chatPartHidden = true; /** Whether the layout hid the chat part, such as for a maximized panel, and shows it again with the editor. */ private chatPartHiddenByLayout = false; + /** The view the chat part docks beside (see {@link setChatPartAnchor}). */ + private chatPartAnchor: { readonly viewId: string } | undefined; + /** The side of the editor the chat part is on in the grid, once there is one. */ + private chatPartPosition: Position.LEFT | Position.RIGHT | undefined; private environmentService!: IBrowserWorkbenchEnvironmentService; private extensionService!: IExtensionService; @@ -1800,6 +1805,15 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.handleContainerDidLayout(this.mainContainer, this._mainContainerDimension); })); this._register(this.storageService.onWillSaveState(() => this.saveChatPartLayout())); + + // The chat part follows its anchor view as the view, its container or its location changes + this._register(Event.any( + this.viewDescriptorService.onDidChangeContainerLocation, + this.viewDescriptorService.onDidChangeContainer, + this.viewDescriptorService.onDidChangeLocation, + this.viewDescriptorService.onDidChangeViewContainers, + Registry.as(ViewExtensions.ViewsRegistry).onViewsRegistered + )(() => this.updateChatPartPosition())); } // The floating cards abut the top window edge only while neither of the two grid rows @@ -2504,9 +2518,47 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi } } - /** The side of the editor the chat part is on: the primary side bar's. */ + setChatPartAnchor(anchor: { readonly viewId: string } | undefined): void { + this.chatPartAnchor = anchor; + this.updateChatPartPosition(); + } + + getChatPartPosition(): Position.LEFT | Position.RIGHT { + return this.chatPartPosition ?? this.computeChatPartPosition(); + } + + /** + * The side of the editor the chat part belongs on: the side of its anchor view's container. A view in a panel at the + * top or bottom, an unknown view, or no anchor take the primary side bar's side. + */ + private computeChatPartPosition(): Position.LEFT | Position.RIGHT { + const sideBarPosition = this.getSideBarPosition() === Position.RIGHT ? Position.RIGHT : Position.LEFT; + const location = this.chatPartAnchor ? this.viewDescriptorService.getViewLocationById(this.chatPartAnchor.viewId) : null; + switch (location) { + case ViewContainerLocation.AuxiliaryBar: + return sideBarPosition === Position.LEFT ? Position.RIGHT : Position.LEFT; + case ViewContainerLocation.Panel: { + const panelPosition = this.getPanelPosition(); + return panelPosition === Position.LEFT || panelPosition === Position.RIGHT ? panelPosition : sideBarPosition; + } + default: + return sideBarPosition; + } + } + + /** Moves the chat part to the side of the editor it belongs on, if it is not there already. */ + private updateChatPartPosition(): void { + if (!this.chatPartView || !this.workbenchGrid || this.chatPartPosition === this.computeChatPartPosition()) { + return; + } + + this.moveChatPartBesideEditor(); + this.updateSashEndpoints(); + } + + /** The side of the editor the chat part is on. */ private getChatPartDirection(): Direction { - return this.getSideBarPosition() === Position.LEFT ? Direction.Left : Direction.Right; + return this.getChatPartPosition() === Position.LEFT ? Direction.Left : Direction.Right; } /** What a part placed beside the editor in `direction` goes beside: the chat part on its side, so that it stays inside. */ @@ -2514,12 +2566,14 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi return this.chatPartView && direction === this.getChatPartDirection() ? this.chatPartView : this.editorPartView; } - /** Moves the chat part immediately beside the editor, on the primary side bar's side, at the size it has. */ + /** Moves the chat part immediately beside the editor, on the side it belongs on, at the size it has. */ private moveChatPartBesideEditor(): void { if (!this.chatPartView) { return; } + this.chatPartPosition = this.computeChatPartPosition(); + const visible = !this.chatPartHidden; const width = visible ? this.workbenchGrid.getViewSize(this.chatPartView).width : undefined; const sizing = width ?? Sizing.Invisible(this.workbenchGrid.getViewCachedVisibleSize(this.chatPartView) ?? this.chatPartView.minimumWidth); @@ -2689,6 +2743,9 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.workbenchGrid.moveView(this.panelPartView, editorHidden ? size.width : this.stateModel.getRuntimeValue(LayoutStateKeys.PANEL_LAST_NON_MAXIMIZED_WIDTH), this.getChatPartOrEditorView(Direction.Left), Direction.Left); } + // The chat part docks beside a view in the panel on the panel's side + this.updateChatPartPosition(); + if (hadFocus) { this.focusPart(Parts.PANEL_PART); } @@ -2813,10 +2870,11 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi }; } - /** Puts the chat node immediately beside the editor node in `nodes`, on the primary side bar's side, out of the editor's width. */ + /** Puts the chat node immediately beside the editor node in `nodes`, on the side it belongs on, out of the editor's width. */ private insertChatNodeBesideEditor(nodes: ISerializedNode[], editor: ISerializedNode, chat: ISerializedNode): void { + this.chatPartPosition = this.computeChatPartPosition(); const index = nodes.indexOf(editor); - nodes.splice(this.stateModel.getRuntimeValue(LayoutStateKeys.SIDEBAR_POSITON) === Position.LEFT ? index : index + 1, 0, chat); + nodes.splice(this.chatPartPosition === Position.LEFT ? index : index + 1, 0, chat); editor.size -= chat.visible ? chat.size : 0; } diff --git a/src/vs/workbench/services/layout/browser/layoutService.ts b/src/vs/workbench/services/layout/browser/layoutService.ts index 353e41e2b5b775..dc37a5ad67ab97 100644 --- a/src/vs/workbench/services/layout/browser/layoutService.ts +++ b/src/vs/workbench/services/layout/browser/layoutService.ts @@ -211,27 +211,20 @@ export function getFloatingOuterEdgeOwners(layoutService: IWorkbenchLayoutServic // this order inward to the first *visible* card, so a hidden editor (e.g. a maximized side // bar that spans the full content width) is skipped and the spanning card is detected on // both edges. - // A chat part, where a window has one, sits immediately beside the editor on the primary side bar's side, inside a + // A chat part, where a window has one, sits immediately beside the editor on `getChatPartPosition()`, inside a // vertical panel on that side. + const chatPartLeft = layoutService.getChatPartPosition() === Position.LEFT; const sideBarGroup: Parts[] = [Parts.ACTIVITYBAR_PART, Parts.SIDEBAR_PART]; const panelGroup: Parts[] = [Parts.PANEL_PART]; - const fullOrder: Parts[] = sideBarLeft - ? [ - ...sideBarGroup, - ...(panelInLeftSequence ? panelGroup : []), - Parts.CHAT_PART, - Parts.EDITOR_PART, - ...(panelInRightSequence ? panelGroup : []), - Parts.AUXILIARYBAR_PART - ] - : [ - Parts.AUXILIARYBAR_PART, - ...(panelInLeftSequence ? panelGroup : []), - Parts.EDITOR_PART, - Parts.CHAT_PART, - ...(panelInRightSequence ? panelGroup : []), - ...[...sideBarGroup].reverse() // activity bar is outermost on the right edge - ]; + const fullOrder: Parts[] = [ + ...(sideBarLeft ? sideBarGroup : [Parts.AUXILIARYBAR_PART]), + ...(panelInLeftSequence ? panelGroup : []), + ...(chatPartLeft ? [Parts.CHAT_PART] : []), + Parts.EDITOR_PART, + ...(chatPartLeft ? [] : [Parts.CHAT_PART]), + ...(panelInRightSequence ? panelGroup : []), + ...(sideBarLeft ? [Parts.AUXILIARYBAR_PART] : [...sideBarGroup].reverse()) // activity bar is outermost on the right edge + ]; return { left: resolveFloatingOuterOwner(layoutService, fullOrder), @@ -662,6 +655,19 @@ export interface IWorkbenchLayoutService extends ILayoutService { */ getSideBarPosition(): Position; + /** + * Docks the chat part beside the view with the given id: it sits immediately beside the editor, on the side of the + * editor where that view's container is, and follows as the view or its container moves or the side bar or panel + * position changes. A view in a panel at the top or bottom, an unknown view, or no anchor keep the chat part on the + * primary side bar's side. + */ + setChatPartAnchor(anchor: { readonly viewId: string } | undefined): void; + + /** + * Gets the side of the editor the chat part is on, whether the window has a chat part or not. + */ + getChatPartPosition(): Position.LEFT | Position.RIGHT; + /** * Toggles the menu bar visibility. */ diff --git a/src/vs/workbench/services/layout/test/browser/layoutService.test.ts b/src/vs/workbench/services/layout/test/browser/layoutService.test.ts index 08d356a762e366..d5a44ddac74789 100644 --- a/src/vs/workbench/services/layout/test/browser/layoutService.test.ts +++ b/src/vs/workbench/services/layout/test/browser/layoutService.test.ts @@ -78,10 +78,12 @@ suite('LayoutService - getFloatingOuterEdgeOwners', () => { sideBarPosition = Position.LEFT; panelPosition = Position.BOTTOM; visibleParts = new Set(); + chatPartPosition: Position.LEFT | Position.RIGHT | undefined; override isFloatingPanelsEnabled(): boolean { return this.floatingPanelsEnabled; } override isModernUICompact(): boolean { return this.modernUICompact; } override getSideBarPosition(): Position { return this.sideBarPosition; } + override getChatPartPosition(): Position.LEFT | Position.RIGHT { return this.chatPartPosition ?? super.getChatPartPosition(); } override getPanelPosition(): Position { return this.panelPosition; } override isVisible(part: Parts): boolean { return this.visibleParts.has(part); } } @@ -133,7 +135,8 @@ suite('LayoutService - getFloatingOuterEdgeOwners', () => { // edge; the secondary side bar still owns the right edge. horizontalPanelVisible: owners(s => { s.panelPosition = Position.BOTTOM; s.visibleParts = new Set([Parts.SIDEBAR_PART, Parts.EDITOR_PART, Parts.PANEL_PART, Parts.AUXILIARYBAR_PART]); }), - // A chat part sits beside the editor on the side bar's side, inside a vertical panel on that side. + // A chat part sits beside the editor on its side (the side bar's by default), inside a vertical panel on that side. + chatPartOnAuxiliaryBarSide: owners(s => { s.chatPartPosition = Position.RIGHT; s.visibleParts = new Set([Parts.SIDEBAR_PART, Parts.EDITOR_PART, Parts.CHAT_PART]); }), chatPartOnly: owners(s => { s.visibleParts = new Set([Parts.CHAT_PART, Parts.EDITOR_PART]); }), chatPartOnlySideBarRight: owners(s => { s.sideBarPosition = Position.RIGHT; s.visibleParts = new Set([Parts.CHAT_PART, Parts.EDITOR_PART]); }), chatPartInsideVerticalPanel: owners(s => { s.panelPosition = Position.LEFT; s.visibleParts = new Set([Parts.PANEL_PART, Parts.CHAT_PART, Parts.EDITOR_PART]); }), @@ -152,6 +155,7 @@ suite('LayoutService - getFloatingOuterEdgeOwners', () => { verticalPanelFull: { left: Parts.ACTIVITYBAR_PART, right: Parts.AUXILIARYBAR_PART }, maximizedVerticalPanel: { left: Parts.PANEL_PART, right: Parts.PANEL_PART }, horizontalPanelVisible: { left: Parts.SIDEBAR_PART, right: Parts.AUXILIARYBAR_PART }, + chatPartOnAuxiliaryBarSide: { left: Parts.SIDEBAR_PART, right: Parts.CHAT_PART }, chatPartOnly: { left: Parts.CHAT_PART, right: Parts.EDITOR_PART }, chatPartOnlySideBarRight: { left: Parts.EDITOR_PART, right: Parts.CHAT_PART }, chatPartInsideVerticalPanel: { left: Parts.PANEL_PART, right: Parts.EDITOR_PART }, diff --git a/src/vs/workbench/test/browser/layout.test.ts b/src/vs/workbench/test/browser/layout.test.ts index 14195bab6ab2b1..48890111b6c0e5 100644 --- a/src/vs/workbench/test/browser/layout.test.ts +++ b/src/vs/workbench/test/browser/layout.test.ts @@ -15,6 +15,7 @@ import { Layout } from '../../browser/layout.js'; import { Part, updateSashEndpoints } from '../../browser/part.js'; import { PanelAlignment, Parts, Position } from '../../services/layout/browser/layoutService.js'; import { NullTelemetryService } from '../../../platform/telemetry/common/telemetryUtils.js'; +import { ViewContainerLocation } from '../../common/views.js'; suite('Workbench layout sash endpoints', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -145,6 +146,8 @@ suite('Workbench layout chat part', () => { readonly alignment: PanelAlignment; /** The chat part, with the layout it was left in; none without a chat part. */ readonly chat?: { readonly visible: boolean; readonly size: number } | 'noLayout'; + /** The location of the view the chat part docks beside; `null` for a view that is not known; no anchor without. */ + readonly anchor?: ViewContainerLocation | null; } /** The state a window left, to start another window from, as after a reload. */ @@ -192,6 +195,7 @@ suite('Workbench layout chat part', () => { ['auxiliaryBar.hidden', false], ]); const initialization = new Map([['sideBar.size', 250], ['auxiliaryBar.size', 260], ['panel.size', 200]]); + const anchor = { location: configuration.anchor ?? null }; let stored = saved ? saved.stored : configuration.chat && configuration.chat !== 'noLayout' ? JSON.stringify({ version: 1, ...configuration.chat }) : undefined; const layout = store.add(new class extends Layout { override isVisible(part: Parts, targetWindow?: Window): boolean { @@ -207,7 +211,7 @@ suite('Workbench layout chat part', () => { telemetryService: NullTelemetryService, // No view containers: the side bar and the panel show and hide empty paneCompositeService: { getActivePaneComposite: () => undefined, getLastActivePaneCompositeId: () => undefined }, - viewDescriptorService: { getViewContainersByLocation: () => [] }, + viewDescriptorService: { getViewContainersByLocation: () => [], getViewLocationById: () => anchor.location }, configurationService: { getValue: () => undefined }, storageService: { get: () => stored, store: (_key: string, value: string) => stored = value }, stateModel: { @@ -216,12 +220,15 @@ suite('Workbench layout chat part', () => { getInitializationValue: (key: { name: string; defaultValue: unknown }) => initialization.get(key.name) ?? key.defaultValue, }, }); + if (configuration.anchor !== undefined) { + layout.setChatPartAnchor({ viewId: 'sessions' }); + } const descriptor = (Reflect.get(layout, 'createGridDescriptor') as () => ISerializedGrid).call(layout); const save = (): ISavedLayout => { (Reflect.get(layout, 'saveChatPartLayout') as () => void).call(layout); return { runtime, stored }; }; - return { layout, views, descriptor, save }; + return { layout, views, descriptor, save, anchor }; } /** The parts of `node`, nested as the grid nests them, marking hidden parts. */ @@ -297,13 +304,13 @@ suite('Workbench layout chat part', () => { }); function createGridLayout(configuration: ILayoutConfiguration, saved?: ISavedLayout) { - const { layout, views, descriptor, save } = createLayout(configuration, saved); + const { layout, views, descriptor, save, anchor } = createLayout(configuration, saved); const grid = store.add(SerializableGrid.deserialize(descriptor, { fromJSON: ({ type }: { type: Parts }) => views.get(type)! }, { proportionalLayout: false })); append(layout.mainContainer, grid.element); grid.layout(1600, 900); Object.assign(layout, { workbenchGrid: grid }); const middle = () => shape((grid.serialize().root.data as ISerializedNode[])[2]); - return { layout, grid, middle, save }; + return { layout, grid, middle, save, anchor, views }; } test('the visible neighbors of the parts beside a chat part are the chat part, as long as it shows', () => { @@ -414,4 +421,95 @@ suite('Workbench layout chat part', () => { const { layout } = createGridLayout(configuration, { ...saved, runtime }); assert.deepStrictEqual({ panelMaximized: layout.isPanelMaximized(), chat: layout.isVisible(Parts.CHAT_PART) }, { panelMaximized: false, chat: true }); }); + + test('docks the chat part beside the editor on the side of its anchor view\'s container', () => { + const middle = (configuration: Omit) => shape((createLayout({ alignment: 'center', chat: { visible: true, size: 400 }, ...configuration }).descriptor.root.data as ISerializedNode[])[2]); + assert.deepStrictEqual({ + sideBar: middle({ sideBar: Position.LEFT, panel: Position.BOTTOM, anchor: ViewContainerLocation.Sidebar }), + sideBarRight: middle({ sideBar: Position.RIGHT, panel: Position.BOTTOM, anchor: ViewContainerLocation.Sidebar }), + auxiliaryBar: middle({ sideBar: Position.LEFT, panel: Position.BOTTOM, anchor: ViewContainerLocation.AuxiliaryBar }), + auxiliaryBarSideBarRight: middle({ sideBar: Position.RIGHT, panel: Position.BOTTOM, anchor: ViewContainerLocation.AuxiliaryBar }), + auxiliaryBarWithPanelOnItsSide: middle({ sideBar: Position.LEFT, panel: Position.RIGHT, anchor: ViewContainerLocation.AuxiliaryBar }), + rightPanel: middle({ sideBar: Position.LEFT, panel: Position.RIGHT, anchor: ViewContainerLocation.Panel }), + bottomPanel: middle({ sideBar: Position.RIGHT, panel: Position.BOTTOM, anchor: ViewContainerLocation.Panel }), + unknownView: middle({ sideBar: Position.LEFT, panel: Position.BOTTOM, anchor: null }), + }, { + sideBar: ['activitybar', 'sidebar', [['chat', 'editor'], 'panel'], 'auxiliarybar'], + sideBarRight: ['auxiliarybar', [['editor', 'chat'], 'panel'], 'sidebar', 'activitybar'], + auxiliaryBar: ['activitybar', 'sidebar', [['editor', 'chat'], 'panel'], 'auxiliarybar'], + auxiliaryBarSideBarRight: ['auxiliarybar', [['chat', 'editor'], 'panel'], 'sidebar', 'activitybar'], + auxiliaryBarWithPanelOnItsSide: ['activitybar', 'sidebar', 'editor', 'chat', 'panel', 'auxiliarybar'], + rightPanel: ['activitybar', 'sidebar', 'editor', 'chat', 'panel', 'auxiliarybar'], + bottomPanel: ['auxiliarybar', [['editor', 'chat'], 'panel'], 'sidebar', 'activitybar'], + unknownView: ['activitybar', 'sidebar', [['chat', 'editor'], 'panel'], 'auxiliarybar'], + }); + }); + + test('the chat part follows its anchor view as the view moves, the side bar flips and the panel moves', () => { + const { layout, middle, anchor } = createGridLayout({ sideBar: Position.LEFT, panel: Position.BOTTOM, alignment: 'center', chat: { visible: true, size: 400 }, anchor: ViewContainerLocation.AuxiliaryBar }); + const setSideBarPosition = (position: Position) => (Reflect.get(layout, 'setSideBarPosition') as (position: Position) => void).call(layout, position); + // As the view descriptor service tells the layout that a view or a view container moved + const moveAnchor = (location: ViewContainerLocation) => { anchor.location = location; (Reflect.get(layout, 'updateChatPartPosition') as () => void).call(layout); }; + const step = () => ({ grid: middle(), position: layout.getChatPartPosition() }); + const steps: Record = {}; + steps.auxiliaryBar = step(); + setSideBarPosition(Position.RIGHT); + steps.sideBarRight = step(); + moveAnchor(ViewContainerLocation.Sidebar); + steps.movedToSideBar = step(); + moveAnchor(ViewContainerLocation.Panel); + layout.setPanelPosition(Position.LEFT); + steps.movedToLeftPanel = step(); + layout.setPanelPosition(Position.RIGHT); + steps.panelRight = step(); + layout.setPanelPosition(Position.BOTTOM); + steps.panelBottom = step(); + layout.setPartHidden(true, Parts.CHAT_PART); + moveAnchor(ViewContainerLocation.AuxiliaryBar); + steps.movedWhileHidden = step(); + layout.setPartHidden(false, Parts.CHAT_PART); + steps.shownAgain = step(); + assert.deepStrictEqual(steps, { + auxiliaryBar: { grid: ['activitybar', 'sidebar', [['editor', 'chat'], 'panel'], 'auxiliarybar'], position: Position.RIGHT }, + sideBarRight: { grid: ['auxiliarybar', [['chat', 'editor'], 'panel'], 'sidebar', 'activitybar'], position: Position.LEFT }, + movedToSideBar: { grid: ['auxiliarybar', [['editor', 'chat'], 'panel'], 'sidebar', 'activitybar'], position: Position.RIGHT }, + movedToLeftPanel: { grid: ['auxiliarybar', 'panel', 'chat', 'editor', 'sidebar', 'activitybar'], position: Position.LEFT }, + panelRight: { grid: ['auxiliarybar', 'editor', 'chat', 'panel', 'sidebar', 'activitybar'], position: Position.RIGHT }, + panelBottom: { grid: ['auxiliarybar', [['editor', 'chat'], 'panel'], 'sidebar', 'activitybar'], position: Position.RIGHT }, + movedWhileHidden: { grid: ['auxiliarybar', [['(chat)', 'editor'], 'panel'], 'sidebar', 'activitybar'], position: Position.LEFT }, + shownAgain: { grid: ['auxiliarybar', [['chat', 'editor'], 'panel'], 'sidebar', 'activitybar'], position: Position.LEFT }, + }); + }); + + test('the chat part keeps its width as it follows its anchor view, shown or hidden', () => { + const { layout, grid, anchor } = createGridLayout({ sideBar: Position.LEFT, panel: Position.BOTTOM, alignment: 'center', chat: { visible: true, size: 400 }, anchor: ViewContainerLocation.AuxiliaryBar }); + const moveAnchor = (location: ViewContainerLocation) => { anchor.location = location; (Reflect.get(layout, 'updateChatPartPosition') as () => void).call(layout); }; + // The width the grid gives the chat part, or would give it once shown + const chatWidth = (node: ISerializedNode): number | undefined => node.type === 'leaf' + ? (node.data as { type: string }).type === Parts.CHAT_PART ? node.size : undefined + : (node.data as ISerializedNode[]).reduce((width, child) => width ?? chatWidth(child), undefined); + const widths: (number | undefined)[] = []; + moveAnchor(ViewContainerLocation.Sidebar); + widths.push(chatWidth(grid.serialize().root)); + moveAnchor(ViewContainerLocation.AuxiliaryBar); + widths.push(chatWidth(grid.serialize().root)); + layout.setPartHidden(true, Parts.CHAT_PART); + moveAnchor(ViewContainerLocation.Sidebar); + layout.setPartHidden(false, Parts.CHAT_PART); + widths.push(chatWidth(grid.serialize().root)); + assert.deepStrictEqual(widths, [400, 400, 400]); + }); + + test('a chat part docked beside the secondary side bar hides while that is maximized and shows again on its side', () => { + const { layout, middle } = createGridLayout({ sideBar: Position.LEFT, panel: Position.BOTTOM, alignment: 'center', chat: { visible: true, size: 400 }, anchor: ViewContainerLocation.AuxiliaryBar }); + const steps: Record = {}; + layout.toggleMaximizedAuxiliaryBar(); + steps.maximized = layout.isVisible(Parts.CHAT_PART); + layout.toggleMaximizedAuxiliaryBar(); + steps.restored = middle(); + assert.deepStrictEqual(steps, { + maximized: false, + restored: ['activitybar', 'sidebar', [['editor', 'chat'], 'panel'], 'auxiliarybar'], + }); + }); }); diff --git a/src/vs/workbench/test/browser/parts/chat/chatHostPart.test.ts b/src/vs/workbench/test/browser/parts/chat/chatHostPart.test.ts index c5513c1a0767f6..05f57553e7da47 100644 --- a/src/vs/workbench/test/browser/parts/chat/chatHostPart.test.ts +++ b/src/vs/workbench/test/browser/parts/chat/chatHostPart.test.ts @@ -83,6 +83,7 @@ suite('ChatHostPart', () => { isModernUICompact: () => false, getPanelSpacing: () => 16, getSideBarPosition: () => Position.LEFT, + getChatPartPosition: () => Position.LEFT, getPanelPosition: () => Position.BOTTOM, getPanelAlignment: (): PanelAlignment => 'center', isVisible: (part: Parts) => visible.has(part), @@ -265,6 +266,7 @@ suite('ChatHostPart', () => { isModernUICompact: () => false, getPanelSpacing: () => 16, getSideBarPosition: () => Position.LEFT, + getChatPartPosition: () => Position.LEFT, getPanelPosition: () => Position.BOTTOM, getPanelAlignment: (): PanelAlignment => 'center', isVisible: (part: Parts) => visible.has(part), @@ -319,6 +321,7 @@ suite('ChatHostPart', () => { isModernUICompact: () => true, getPanelSpacing: () => 0, getSideBarPosition: () => Position.LEFT, + getChatPartPosition: () => Position.LEFT, getPanelPosition: () => Position.BOTTOM, getPanelAlignment: (): PanelAlignment => 'center', isVisible: (part: Parts) => part !== Parts.PANEL_PART, diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index e502d0e6067773..f11630494ad38b 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -711,6 +711,8 @@ export class TestLayoutService implements IWorkbenchLayoutService { getMenubarVisibility(): MenuBarVisibility { throw new Error('not implemented'); } toggleMenuBar(): void { } getSideBarPosition() { return 0; } + setChatPartAnchor(): void { } + getChatPartPosition(): PartPosition.LEFT | PartPosition.RIGHT { return this.getSideBarPosition() === PartPosition.RIGHT ? PartPosition.RIGHT : PartPosition.LEFT; } getPanelPosition() { return 0; } getPanelAlignment(): PanelAlignment { return 'center'; } async setPanelPosition(_position: PartPosition): Promise { } From 018a7e8c0fa0477e95ac1087065b5ab80bd11384 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 03:01:35 +0100 Subject: [PATCH 002/165] Agent host sessions: keep sessions listed when switching a folder's location Switching the folder of a single-folder window to another location, such as a linked checkout from the folder location status bar entry, changes its workspace folders. The agent host session list then dropped every session outside the new location, which disposes the content of their open chats and leaves them read-only. Keep matching sessions against every location the folder was at while the window shows the same workspace. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostSessionListStore.ts | 34 ++++++++++++++- .../agentHostChatContribution.test.ts | 43 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts index 13dfdc7865b417..1bd7a68aeed899 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts @@ -106,6 +106,13 @@ export class AgentHostSessionListStore extends Disposable { /** Sessions already reported as having an unusable (non-local) project root. */ private readonly _reportedNonLocalProjects = new Set(); + /** + * The locations the folder of a single-folder window was at since the window showed its workspace, such as a linked + * checkout it moved to and back from: the same workspace, whose sessions stay listed while its folder moves. Removing a + * session from the list disposes the content of its chats that are open, which turns them read-only. + */ + private readonly _folderLocations: URI[] = []; + constructor( private readonly _connection: IAgentHostSessionListConnection, private readonly _options: { @@ -135,10 +142,12 @@ export class AgentHostSessionListStore extends Disposable { // Re-fetch the session list whenever the set of VS Code workspace // folders changes, since filtering depends on it. The agent host itself // doesn't know which workspace this VS Code window has open. + this._trackFolderLocation(); this._register(this._workspaceContextService.onDidChangeWorkspaceFolders(() => { if (this._options.filterToWorkspace === false) { return; } + this._trackFolderLocation(); this._cacheValid = false; this._filterEntriesToWorkspace(); void this.refresh(CancellationToken.None); @@ -550,7 +559,30 @@ export class AgentHostSessionListStore extends Disposable { if (folders.length === 0) { return true; } - return this._matchesAnyFolder(workingDirectories, folders); + return this._matchesAnyFolder(workingDirectories, folders) || this._matchesAnyLocation(workingDirectories); + } + + private _trackFolderLocation(): void { + const workspace = this._workspaceContextService.getWorkspace(); + if (workspace.configuration || !workspace.uri || workspace.folders.length !== 1) { + this._folderLocations.length = 0; + return; + } + // Another workspace starts over: its folder is first at the workspace's own location + if (!this._folderLocations.some(location => extUriBiasedIgnorePathCase.isEqual(location, workspace.uri))) { + this._folderLocations.length = 0; + this._folderLocations.push(workspace.uri); + } + const location = workspace.folders[0].uri; + if (!this._folderLocations.some(known => extUriBiasedIgnorePathCase.isEqual(known, location))) { + this._folderLocations.push(location); + } + } + + private _matchesAnyLocation(workingDirectories: readonly URI[]): boolean { + return workingDirectories.some(directory => + this._folderLocations.some(location => extUriBiasedIgnorePathCase.isEqualOrParent(directory, location)) + ); } private _filterEntriesToWorkspace(): void { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index c0e8a4b18ed7a7..43db9f5d0c7253 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -7289,6 +7289,49 @@ suite('AgentHostChatContribution', () => { }); }); + test('switching the folder of a single-folder window to another location and back keeps its sessions listed', async () => { + const { instantiationService, agentHostService } = createTestServices(disposables); + + // The folder of the window moves to a linked checkout, as its folder location: the workspace stays the same + const primary = URI.file('/workspace/root'); + const checkout = URI.file('/workspace/root.worktrees/feature'); + let folder = primary; + const onDidChangeWorkspaceFolders = disposables.add(new Emitter<{ readonly added: never[]; readonly removed: never[]; readonly changed: never[] }>()); + instantiationService.stub(IWorkspaceContextService, { + getWorkbenchState: () => WorkbenchState.FOLDER, + getWorkspace: () => testIWorkspace({ id: '', uri: primary, folders: [{ uri: folder, name: 'root', index: 0, toResource: () => folder }] }), + getWorkspaceFolder: () => null, + onDidChangeWorkspaceFolders: onDidChangeWorkspaceFolders.event, + }); + + agentHostService.addSession({ session: AgentSession.uri('copilot', 'primary'), startTime: 1000, modifiedTime: 2000, summary: 'In the folder', workingDirectories: [URI.file('/workspace/root/sub')] }); + agentHostService.addSession({ session: AgentSession.uri('copilot', 'checkout'), startTime: 1000, modifiedTime: 3000, summary: 'In the checkout', workingDirectories: [checkout] }); + agentHostService.addSession({ session: AgentSession.uri('copilot', 'elsewhere'), startTime: 1000, modifiedTime: 1500, summary: 'Elsewhere', workingDirectories: [URI.file('/other/place')] }); + + const listController = createSessionListController(disposables, instantiationService, agentHostService); + const removed: string[] = []; + disposables.add(listController.onDidChangeChatSessionItems(delta => removed.push(...(delta.removed ?? []).map(resource => resource.path)))); + const labels = () => listController.items.map(item => item.label).sort(); + + await listController.refresh(CancellationToken.None); + const atPrimary = labels(); + folder = checkout; + onDidChangeWorkspaceFolders.fire({ added: [], removed: [], changed: [] }); + await timeout(0); + const atCheckout = labels(); + folder = primary; + onDidChangeWorkspaceFolders.fire({ added: [], removed: [], changed: [] }); + await timeout(0); + + // Removing a session from the list disposes the content of its chats that are open, which turns them read-only + assert.deepStrictEqual({ atPrimary, atCheckout, back: labels(), removed }, { + atPrimary: ['In the folder'], + atCheckout: ['In the checkout', 'In the folder'], + back: ['In the checkout', 'In the folder'], + removed: [], + }); + }); + test('workspace folder change re-filters the retained snapshot when refresh fails', async () => { const { instantiationService, agentHostService } = createTestServices(disposables); const workspaceFolder = URI.file('/workspace/root'); From b3f66cfc66511f3fa8c7b0dbde131d14517318b7 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 03:20:04 +0100 Subject: [PATCH 003/165] Sessions: shared fixes for hosting sessions UI outside the Agents window - Move the phone's null chat window service to chatWindow as NullChatWindowService, so any window without separate chat windows can provide it. - Register the session attention service in its own module, once for every window that loads it. - Hide the composer toolbar, model picker and agent host pickers and their keybinding while AI features are disabled. - Find sessions outside a provider's listing scope when reopening a closed item or resolving session settings. - Remove the stale vs/sessions/contrib/chatEditor translation resource, whose folder moved to vs/workbench. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build/lib/i18n.resources.json | 4 -- .../sessionsPresentationDependencies.test.ts | 2 +- .../browser/openSessionLinkOpener.test.ts | 4 +- .../mobile/browser/mobile.contribution.ts | 4 +- .../test/browser/mobilePresentation.test.ts | 4 +- .../agentHostSessionConfigPicker.test.ts | 10 ++++- .../test/browser/agentHostAgentPicker.test.ts | 2 + .../agentHostChatInputPickersScope.test.ts | 1 + .../agentsWindowRegistrations.snapshot.ts | 40 +++++++++---------- .../sessions/browser/sessions.contribution.ts | 6 +-- .../browser/nullChatWindowService.ts} | 11 +++-- .../sessions/browser/chat/modelPicker.ts | 2 +- .../chat/sharedChatInputComposerToolbar.ts | 7 +++- .../sessions/browser/closedItemHistory.ts | 4 +- .../browser/sessionAttentionService.ts | 4 ++ .../agentHost/browser/agentHostAgentPicker.ts | 6 +-- .../browser/agentHostSessionConfigPicker.ts | 21 +++++----- .../agentSessionSettingsFileSystemProvider.ts | 4 +- 18 files changed, 76 insertions(+), 60 deletions(-) rename src/vs/{sessions/contrib/mobile/browser/mobileNullChatWindowService.ts => workbench/contrib/chatWindow/browser/nullChatWindowService.ts} (75%) diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index 1d3b7de8e817fc..47cbe1b6507522 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -748,10 +748,6 @@ "name": "vs/sessions/contrib/chat", "project": "vscode-sessions" }, - { - "name": "vs/sessions/contrib/chatEditor", - "project": "vscode-sessions" - }, { "name": "vs/sessions/contrib/chatWindow", "project": "vscode-sessions" diff --git a/build/lib/test/sessionsPresentationDependencies.test.ts b/build/lib/test/sessionsPresentationDependencies.test.ts index e6a97a938f0722..05dab84a64d901 100644 --- a/build/lib/test/sessionsPresentationDependencies.test.ts +++ b/build/lib/test/sessionsPresentationDependencies.test.ts @@ -149,7 +149,7 @@ suite('Sessions presentation entry isolation', () => { }, { canvas: ['IWindowCanvasService', 'WindowCanvasService'], pinnedCanvas: mobile ? undefined : ['IPinnedCanvasService', 'PinnedCanvasService'], - chatWindow: ['IChatWindowService', mobile ? 'MobileNullChatWindowService' : 'ChatWindowService'], + chatWindow: ['IChatWindowService', mobile ? 'NullChatWindowService' : 'ChatWindowService'], presentation: mobile ? [] : desktopPresentation, }); }); diff --git a/src/vs/sessions/contrib/chat/test/browser/openSessionLinkOpener.test.ts b/src/vs/sessions/contrib/chat/test/browser/openSessionLinkOpener.test.ts index 15e51cd26a3502..68dad017f783aa 100644 --- a/src/vs/sessions/contrib/chat/test/browser/openSessionLinkOpener.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/openSessionLinkOpener.test.ts @@ -36,7 +36,7 @@ import { IChat, ISession, SessionStatus } from '../../../../../workbench/service import { ISessionsProvider } from '../../../../../workbench/contrib/sessions/common/sessionsProvider.js'; import { ISessionsManagementService } from '../../../../../workbench/contrib/sessions/common/sessionsManagement.js'; import { findSessionForOpenSessionLink, ISessionLinkChatState, ISessionLinkState, OpenSessionLinkOpenerContribution, readSessionState } from '../../browser/openSessionLinkOpener.contribution.js'; -import { MobileNullChatWindowService } from '../../../mobile/browser/mobileNullChatWindowService.js'; +import { NullChatWindowService } from '../../../../../workbench/contrib/chatWindow/browser/nullChatWindowService.js'; suite('OpenSessionLinkOpenerContribution', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -180,7 +180,7 @@ suite('OpenSessionLinkOpenerContribution', () => { sessionsProvidersService, sessionSummaryHoverService, new class extends mock() { }, - new MobileNullChatWindowService(), + new NullChatWindowService(), new TestConfigurationService({ [CHAT_WINDOWS_ENABLED_SETTING]: true }), )); diff --git a/src/vs/sessions/contrib/mobile/browser/mobile.contribution.ts b/src/vs/sessions/contrib/mobile/browser/mobile.contribution.ts index ec9369feba0c93..fd2fb361b6e313 100644 --- a/src/vs/sessions/contrib/mobile/browser/mobile.contribution.ts +++ b/src/vs/sessions/contrib/mobile/browser/mobile.contribution.ts @@ -21,7 +21,7 @@ import { MobileContextMenuService } from './mobileContextMenuService.js'; import { MobileKeybindingService } from './mobileKeybindingService.js'; import { MobileNullAgentHostTerminalService } from './mobileNullAgentHostTerminalService.js'; import { MobileNullAquariumService } from './mobileNullAquariumService.js'; -import { MobileNullChatWindowService } from './mobileNullChatWindowService.js'; +import { NullChatWindowService } from '../../../../workbench/contrib/chatWindow/browser/nullChatWindowService.js'; import { MobileQuickInputService } from './mobileQuickInputService.js'; import { ISessionsListPresentation } from '../../../../workbench/contrib/sessions/browser/sessionsListPresentation.js'; import { ISessionRepositoryPicker } from '../../../../workbench/contrib/sessions/browser/sessionRepositoryPicker.js'; @@ -70,7 +70,7 @@ registerSingleton(IAgentHostTerminalService, MobileNullAgentHostTerminalService, // The aquarium easter egg does not exist on the phone. See MobileNullAquariumService. registerSingleton(IAquariumService, MobileNullAquariumService, InstantiationType.Delayed); -registerSingleton(IChatWindowService, MobileNullChatWindowService, InstantiationType.Delayed); +registerSingleton(IChatWindowService, NullChatWindowService, InstantiationType.Delayed); registerSingleton(IWindowCanvasService, WindowCanvasService, InstantiationType.Delayed); registerSingleton(ISessionsListPresentation, MobileSessionsListPresentation, InstantiationType.Delayed); registerSingleton(ISessionRepositoryPicker, MobileRepositoryPicker, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/mobile/test/browser/mobilePresentation.test.ts b/src/vs/sessions/contrib/mobile/test/browser/mobilePresentation.test.ts index 090bb6ef1d2a9e..cf1680e06751f0 100644 --- a/src/vs/sessions/contrib/mobile/test/browser/mobilePresentation.test.ts +++ b/src/vs/sessions/contrib/mobile/test/browser/mobilePresentation.test.ts @@ -39,7 +39,7 @@ import { SessionsGridContent } from '../../../../../workbench/contrib/sessions/b import { ISessionsPartContentHost } from '../../../../../workbench/browser/parts/sessions/sessionsPartContent.js'; import { MobileChatEditPresenter } from '../../browser/mobileChatEditPresenter.js'; import { MobileChatViewFactory } from '../../browser/mobileChatViewFactory.js'; -import { MobileNullChatWindowService } from '../../browser/mobileNullChatWindowService.js'; +import { NullChatWindowService } from '../../../../../workbench/contrib/chatWindow/browser/nullChatWindowService.js'; import { MobileSessionsListPresentation } from '../../browser/mobileSessionsListPresentation.js'; suite('Mobile presentation composition', () => { @@ -47,7 +47,7 @@ suite('Mobile presentation composition', () => { teardown(() => sinon.restore()); test('separate chat windows cannot be activated by mobile consumers', async () => { - const windows = new MobileNullChatWindowService(); + const windows = new NullChatWindowService(); await assert.rejects(windows.open(URI.parse('chat:/test')), /Separate chat windows are not available/); assert.throws(() => windows.registerProvider(upcastPartial({})), /Separate chat windows are not available/); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts index 6750158e5b2c2f..a912cbbb4b037e 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts @@ -933,7 +933,7 @@ suite('Agent Host Session Config Picker', () => { }); }); - test('places running session controls from the scoped composer layout', () => { + test('places running session controls from the scoped composer layout, and none with AI features disabled', () => { const runningSessionIds = [ 'sessions.agentHost.runningSessionModePicker', 'sessions.agentHost.runningSessionConfigPicker', @@ -941,6 +941,7 @@ suite('Agent Host Session Config Picker', () => { 'sessions.agentHost.runningSessionCodexApprovalsPicker', ]; const context = new Context(1, null); + context.setValue(ChatContextKeys.enabled.key, true); context.setValue(ChatContextKeys.chatIsAgentHostSession.key, true); // The chat input of a session host's scope. context.setValue(InSessionHostScopeContext.key, true); @@ -960,10 +961,15 @@ suite('Agent Host Session Config Picker', () => { primary: visible(MenuId.ChatInput), secondary: visible(MenuId.ChatInputSecondary), }; + context.setValue(ChatContextKeys.enabled.key, false); + const aiDisabledExperimental = [...visible(MenuId.ChatInput), ...visible(MenuId.ChatInputSecondary)]; + context.setValue(SessionUsesExperimentalComposerLayoutContext.key, false); + const aiDisabled = { experimental: aiDisabledExperimental, legacy: [...visible(MenuId.ChatInput), ...visible(MenuId.ChatInputSecondary)] }; - assert.deepStrictEqual({ legacy, experimental }, { + assert.deepStrictEqual({ legacy, experimental, aiDisabled }, { legacy: { primary: [], secondary: runningSessionIds }, experimental: { primary: runningSessionIds, secondary: [] }, + aiDisabled: { experimental: [], legacy: [] }, }); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAgentPicker.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAgentPicker.test.ts index 3cf24973a83230..6716674901b928 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAgentPicker.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostAgentPicker.test.ts @@ -139,6 +139,7 @@ suite('agentHostAgentPicker', () => { test('keeps the new-session agent in the session options', () => { const context = disposables.add(new ContextKeyService(new TestConfigurationService())); + ChatContextKeys.enabled.bindTo(context).set(true); SessionProviderIdContext.bindTo(context).set(LOCAL_AGENT_HOST_PROVIDER_ID); IsPhoneLayoutContext.bindTo(context).set(false); ChatContextKeys.inAutomationsDialog.bindTo(context).set(false); @@ -191,6 +192,7 @@ suite('agentHostAgentPicker', () => { const configuration = new TestConfigurationService(); disposables.add(configuration.onDidChangeConfigurationEmitter); const context = disposables.add(new ContextKeyService(configuration)); + ChatContextKeys.enabled.bindTo(context).set(true); SessionProviderIdContext.bindTo(context).set(LOCAL_AGENT_HOST_PROVIDER_ID); IsPhoneLayoutContext.bindTo(context).set(false); ChatContextKeys.inAutomationsDialog.bindTo(context).set(false); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostChatInputPickersScope.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostChatInputPickersScope.test.ts index d3d688a984579a..2262444cb92be1 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostChatInputPickersScope.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostChatInputPickersScope.test.ts @@ -57,6 +57,7 @@ suite('Agent Host chat input pickers: session host scopes', () => { function setUpScopes() { const root = store.add(new ContextKeyService(new TestConfigurationService())); // Everything else the pickers need holds: only the scope tells them apart. + ChatContextKeys.enabled.bindTo(root).set(true); ChatContextKeys.chatIsAgentHostSession.bindTo(root).set(true); IsSessionsWindowContext.bindTo(root).set(true); const gridHost = hostOf(SessionHostKind.MainWindow, root); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentsWindowRegistrations.snapshot.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentsWindowRegistrations.snapshot.ts index 27d8147bd3fda2..4b3a99b0bbc47f 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentsWindowRegistrations.snapshot.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentsWindowRegistrations.snapshot.ts @@ -95,15 +95,15 @@ export const AGENTS_WINDOW_REGISTRATIONS: readonly string[] = [ 'menu AutomationsDialogInputToolbar command sessions.modelPicker group navigation order 1 when chatIsEnabled && inAutomationsDialog && !sessionUsesCombinedConfigPicker || chatIsEnabled && inAutomationsDialog && !sessionsIsPhoneLayout', 'menu AutomationsDialogInputToolbar command sessions.agentHost.agentPicker group navigation order -1 when chatIsEnabled && inAutomationsDialog && !sessionsIsPhoneLayout && sessionProviderId == \'local-agent-host\' || chatIsEnabled && inAutomationsDialog && !sessionsIsPhoneLayout && sessionProviderId =~ /^agenthost-/', 'menu ChatExecuteSecondary command sessions.sendLater group navigation order 99 when chatIsEnabled && config.sessions.sendLater.enabled && isSessionsWindow && sessionsChatInputCanSchedule', - 'menu ChatInput command sessions.agentHost.agentPicker group navigation order 0 when chatIsAgentHostSession && inSessionHostScope && isSessionsWindow && !chatHasPendingDelegationTarget && !sessionsIsPhoneLayout', - 'menu ChatInput command sessions.agentHost.runningSessionConfigPicker group navigation order 0.2 when chatIsAgentHostSession && inSessionHostScope && sessionUsesExperimentalComposerLayout', - 'menu ChatInput command sessions.agentHost.runningSessionPermissionModePicker group navigation order 0.3 when chatIsAgentHostSession && inSessionHostScope && sessionUsesExperimentalComposerLayout', - 'menu ChatInput command sessions.agentHost.runningSessionCodexApprovalsPicker group navigation order 0.4 when chatIsAgentHostSession && inSessionHostScope && sessionUsesExperimentalComposerLayout', - 'menu ChatInput command sessions.agentHost.runningSessionModePicker group navigation order 0.1 when chatIsAgentHostSession && inSessionHostScope && sessionUsesExperimentalComposerLayout && !chatHasPendingDelegationTarget', - 'menu ChatInputSecondary command sessions.agentHost.runningSessionConfigPicker group navigation order 10 when chatIsAgentHostSession && inSessionHostScope && !sessionUsesExperimentalComposerLayout', - 'menu ChatInputSecondary command sessions.agentHost.runningSessionPermissionModePicker group navigation order 11 when chatIsAgentHostSession && inSessionHostScope && !sessionUsesExperimentalComposerLayout', - 'menu ChatInputSecondary command sessions.agentHost.runningSessionCodexApprovalsPicker group navigation order 12 when chatIsAgentHostSession && inSessionHostScope && !sessionUsesExperimentalComposerLayout', - 'menu ChatInputSecondary command sessions.agentHost.runningSessionModePicker group navigation order 9 when chatIsAgentHostSession && inSessionHostScope && !chatHasPendingDelegationTarget && !sessionUsesExperimentalComposerLayout', + 'menu ChatInput command sessions.agentHost.agentPicker group navigation order 0 when chatIsAgentHostSession && chatIsEnabled && inSessionHostScope && isSessionsWindow && !chatHasPendingDelegationTarget && !sessionsIsPhoneLayout', + 'menu ChatInput command sessions.agentHost.runningSessionConfigPicker group navigation order 0.2 when chatIsAgentHostSession && chatIsEnabled && inSessionHostScope && sessionUsesExperimentalComposerLayout', + 'menu ChatInput command sessions.agentHost.runningSessionPermissionModePicker group navigation order 0.3 when chatIsAgentHostSession && chatIsEnabled && inSessionHostScope && sessionUsesExperimentalComposerLayout', + 'menu ChatInput command sessions.agentHost.runningSessionCodexApprovalsPicker group navigation order 0.4 when chatIsAgentHostSession && chatIsEnabled && inSessionHostScope && sessionUsesExperimentalComposerLayout', + 'menu ChatInput command sessions.agentHost.runningSessionModePicker group navigation order 0.1 when chatIsAgentHostSession && chatIsEnabled && inSessionHostScope && sessionUsesExperimentalComposerLayout && !chatHasPendingDelegationTarget', + 'menu ChatInputSecondary command sessions.agentHost.runningSessionConfigPicker group navigation order 10 when chatIsAgentHostSession && chatIsEnabled && inSessionHostScope && !sessionUsesExperimentalComposerLayout', + 'menu ChatInputSecondary command sessions.agentHost.runningSessionPermissionModePicker group navigation order 11 when chatIsAgentHostSession && chatIsEnabled && inSessionHostScope && !sessionUsesExperimentalComposerLayout', + 'menu ChatInputSecondary command sessions.agentHost.runningSessionCodexApprovalsPicker group navigation order 12 when chatIsAgentHostSession && chatIsEnabled && inSessionHostScope && !sessionUsesExperimentalComposerLayout', + 'menu ChatInputSecondary command sessions.agentHost.runningSessionModePicker group navigation order 9 when chatIsAgentHostSession && chatIsEnabled && inSessionHostScope && !chatHasPendingDelegationTarget && !sessionUsesExperimentalComposerLayout', 'menu CommandPalette command sessionsViewPane.closeSession group order when !editorIsOpen && !isNewChatSession', 'menu CommandPalette command sessionsViewPane.navigatePreviousSession group order when ', 'menu CommandPalette command sessionsViewPane.navigateNextSession group order when ', @@ -113,16 +113,16 @@ export const AGENTS_WINDOW_REGISTRATIONS: readonly string[] = [ 'menu CommandPalette command sessions.sendInBackground group order when chatInputHasSendableContent && chatIsEnabled && chatSessionOptionsValid && isSessionsWindow && sessionsChatInputSupportsBackground && !chatInputBlocked', 'menu CommandPalette command sessions.sendLater group order when chatInputHasSendableContent && chatIsEnabled && isSessionsWindow && sessionsChatInputCanSchedule && !chatInputBlocked', 'menu CommandPalette command sessions.compareModels group order when chatIsEnabled && chatModelComparisonAvailable && isSessionsWindow', - 'menu NewSessions.ChatInputToolbar command workbench.action.chat.attachContext group navigation order -2 when ', - 'menu NewSessions.ChatInputToolbar command sessions.newChatComposer.initializing group navigation order 100 when sessionsSharedChatInputComposerBusy == \'initializing\'', - 'menu NewSessions.ChatInputToolbar command sessions.newChatComposer.sending group navigation order 100 when sessionsSharedChatInputComposerBusy == \'sending\'', - 'menu NewSessions.ChatInputToolbar command sessions.newChatComposer.sessionControls group navigation order 0 when ', - 'menu NewSessions.ChatInputToolbar command sessions.modelPicker group navigation order 1 when !sessionUsesCombinedConfigPicker || !sessionsIsPhoneLayout', - 'menu NewSessions.SessionControlMenu command sessions.agentHost.agentPicker group navigation order -1 when !inAutomationsDialog && !sessionsIsPhoneLayout && sessionProviderId == \'local-agent-host\' || !inAutomationsDialog && !sessionsIsPhoneLayout && sessionProviderId =~ /^agenthost-/', - 'menu NewSessions.SessionControlMenu command sessions.agentHost.newSessionApprovePicker group navigation order 1 when sessionProviderId == \'local-agent-host\' || sessionProviderId =~ /^agenthost-/', - 'menu NewSessions.SessionControlMenu command sessions.agentHost.newSessionPermissionModePicker group navigation order 2 when sessionProviderId == \'local-agent-host\' || sessionProviderId =~ /^agenthost-/', - 'menu NewSessions.SessionControlMenu command sessions.agentHost.newSessionCodexApprovalsPicker group navigation order 3 when sessionProviderId == \'local-agent-host\' || sessionProviderId =~ /^agenthost-/', - 'menu NewSessions.SessionControlMenu command sessions.agentHost.newSessionModePicker group navigation order 0 when !sessionsIsPhoneLayout && sessionProviderId == \'local-agent-host\' || !sessionsIsPhoneLayout && sessionProviderId =~ /^agenthost-/', + 'menu NewSessions.ChatInputToolbar command workbench.action.chat.attachContext group navigation order -2 when chatIsEnabled', + 'menu NewSessions.ChatInputToolbar command sessions.newChatComposer.initializing group navigation order 100 when chatIsEnabled && sessionsSharedChatInputComposerBusy == \'initializing\'', + 'menu NewSessions.ChatInputToolbar command sessions.newChatComposer.sending group navigation order 100 when chatIsEnabled && sessionsSharedChatInputComposerBusy == \'sending\'', + 'menu NewSessions.ChatInputToolbar command sessions.newChatComposer.sessionControls group navigation order 0 when chatIsEnabled', + 'menu NewSessions.ChatInputToolbar command sessions.modelPicker group navigation order 1 when chatIsEnabled && !sessionUsesCombinedConfigPicker || chatIsEnabled && !sessionsIsPhoneLayout', + 'menu NewSessions.SessionControlMenu command sessions.agentHost.agentPicker group navigation order -1 when chatIsEnabled && !inAutomationsDialog && !sessionsIsPhoneLayout && sessionProviderId == \'local-agent-host\' || chatIsEnabled && !inAutomationsDialog && !sessionsIsPhoneLayout && sessionProviderId =~ /^agenthost-/', + 'menu NewSessions.SessionControlMenu command sessions.agentHost.newSessionApprovePicker group navigation order 1 when chatIsEnabled && sessionProviderId == \'local-agent-host\' || chatIsEnabled && sessionProviderId =~ /^agenthost-/', + 'menu NewSessions.SessionControlMenu command sessions.agentHost.newSessionPermissionModePicker group navigation order 2 when chatIsEnabled && sessionProviderId == \'local-agent-host\' || chatIsEnabled && sessionProviderId =~ /^agenthost-/', + 'menu NewSessions.SessionControlMenu command sessions.agentHost.newSessionCodexApprovalsPicker group navigation order 3 when chatIsEnabled && sessionProviderId == \'local-agent-host\' || chatIsEnabled && sessionProviderId =~ /^agenthost-/', + 'menu NewSessions.SessionControlMenu command sessions.agentHost.newSessionModePicker group navigation order 0 when chatIsEnabled && !sessionsIsPhoneLayout && sessionProviderId == \'local-agent-host\' || chatIsEnabled && !sessionsIsPhoneLayout && sessionProviderId =~ /^agenthost-/', 'menu NewSessions.SessionControlMenu command sessions.sandbox.modePicker group navigation order 0 when chatIsEnabled && isNewChatSession && sessionProviderId == \'cloud-sandbox-creation\' || chatIsEnabled && isNewChatSession && sessionProviderId == \'default-copilot\'', 'menu NewSessions.SessionControlMenu command sessions.sandbox.approvalPicker group navigation order 1 when chatIsEnabled && isNewChatSession && sessionProviderId == \'cloud-sandbox-creation\' || chatIsEnabled && isNewChatSession && sessionProviderId == \'default-copilot\'', 'menu SessionGroupToolbar command sessionsView.deleteEmptyGroup group navigation order 2 when sessionGroup.isEmpty && !sessionGroup.isComparison', @@ -201,7 +201,7 @@ export const AGENTS_WINDOW_REGISTRATIONS: readonly string[] = [ 'menu SessionsViewShow command sessionsViewPane.showRecentSessions group 1_show order 0 when !isSessionsWindow && sessionsViewPane.grouping == \'worktree\'', 'menu SessionsViewShow command sessionsViewPane.showAllSessions group 1_show order 1 when sessionsViewPane.grouping == \'environment\' || sessionsViewPane.grouping == \'repository\' || sessionsViewPane.grouping == \'workspace\'', 'menu SessionsViewShow command sessionsViewPane.showAllSessions group 1_show order 1 when !isSessionsWindow && sessionsViewPane.grouping == \'worktree\'', - 'keybinding sessions.agentHost.openAgentPicker weight 101 when inChatInput && sessionActiveChatIsUntitled && sessionProviderId == \'local-agent-host\' || inChatInput && sessionActiveChatIsUntitled && sessionProviderId =~ /^agenthost-/', + 'keybinding sessions.agentHost.openAgentPicker weight 101 when chatIsEnabled && inChatInput && sessionActiveChatIsUntitled && sessionProviderId == \'local-agent-host\' || chatIsEnabled && inChatInput && sessionActiveChatIsUntitled && sessionProviderId =~ /^agenthost-/', 'keybinding agentSession.markRead weight 250 when isSessionsWindow && listFocus && !inputFocus && !treestickyScrollFocused && focusedView == \'sessions.workbench.view.sessionsView\'', 'keybinding agentSession.markUnread weight 250 when isSessionsWindow && listFocus && !inputFocus && !treestickyScrollFocused && focusedView == \'sessions.workbench.view.sessionsView\'', 'keybinding agentic.workbench.view.sessionsContainer weight 250 when viewContainer.agentic.workbench.view.sessionsContainer.enabled', diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index 5123b3594fbffc..09dbb5ae8b1270 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; -import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { IViewDescriptor, IViewsRegistry, Extensions as ViewContainerExtensions, WindowEnablement, ViewContainer, IViewContainersRegistry, ViewContainerLocation } from '../../../../workbench/common/views.js'; import { localize, localize2 } from '../../../../nls.js'; @@ -33,13 +32,10 @@ import { SESSIONS_MARK_AS_DONE_CONFETTI_SETTING } from '../../../../platform/cha import { SessionsWindowNotifier } from './sessionsWindowNotifier.js'; import { AutomationSessionsListingContribution } from './automationSessionsListing.js'; import { SessionsUnattendedRestartParticipant } from './sessionsUnattendedRestart.js'; -import { ISessionAttentionService } from '../../../../workbench/contrib/sessions/common/sessionAttentionService.js'; -import { SessionAttentionService } from '../../../../workbench/contrib/sessions/browser/sessionAttentionService.js'; +import '../../../../workbench/contrib/sessions/browser/sessionAttentionService.js'; import { SESSIONS_CHAT_TABS_DEFAULT, SESSIONS_CHAT_TABS_SETTING, SESSIONS_DRAFTS_ENABLED_SETTING, SESSIONS_SEND_LATER_ENABLED_SETTING, SESSIONS_LIST_AGENT_TOOLS_SETTING, SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING, SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SHOW_FROM_SETTING, SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING, SESSIONS_LIST_INBOX_ENABLED_SETTING, SESSIONS_LIST_SHOW_AUTOMATION_SESSIONS_SETTING, SESSIONS_SIDEBAR_SEPARATE_NAVIGATION_SETTING, SESSIONS_SIDEBAR_CUSTOMIZATIONS_AT_BOTTOM_SETTING, SESSIONS_LIST_REARRANGE_TREATMENT, SessionsChatTabsMode, USE_WORKTREE_SETTING } from '../../../../workbench/contrib/sessions/common/sessionConfig.js'; import { sessionsListConfigurationProperties } from '../../../../workbench/contrib/sessions/browser/sessionsConfiguration.js'; -registerSingleton(ISessionAttentionService, SessionAttentionService, InstantiationType.Delayed); - 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"); const SessionsContainerId = 'agentic.workbench.view.sessionsContainer'; diff --git a/src/vs/sessions/contrib/mobile/browser/mobileNullChatWindowService.ts b/src/vs/workbench/contrib/chatWindow/browser/nullChatWindowService.ts similarity index 75% rename from src/vs/sessions/contrib/mobile/browser/mobileNullChatWindowService.ts rename to src/vs/workbench/contrib/chatWindow/browser/nullChatWindowService.ts index 05f815fe7f4a90..e1f970869df5cd 100644 --- a/src/vs/sessions/contrib/mobile/browser/mobileNullChatWindowService.ts +++ b/src/vs/workbench/contrib/chatWindow/browser/nullChatWindowService.ts @@ -6,13 +6,16 @@ import { IDisposable } from '../../../../base/common/lifecycle.js'; import { constObservable } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; -// eslint-disable-next-line local/code-translation-remind -- Experimental entry is excluded from production translation resources. import { localize } from '../../../../nls.js'; -import { IChatWindow, IChatWindowNewChatOptions, IChatWindowOpenOptions, IChatWindowProvider, IChatWindowService } from '../../../../workbench/contrib/chatWindow/browser/chatWindow.js'; +import { IChatWindow, IChatWindowNewChatOptions, IChatWindowOpenOptions, IChatWindowProvider, IChatWindowService } from './chatWindow.js'; -const unavailableMessage = localize('mobile.chatWindowsUnavailable', "Separate chat windows are not available on this device."); +const unavailableMessage = localize('chatWindowsUnavailable', "Separate chat windows are not available here."); -export class MobileNullChatWindowService implements IChatWindowService { +/** + * The chat windows of where separate chat windows are not available, such as an editor window or a phone: there are none, + * and none open. + */ +export class NullChatWindowService implements IChatWindowService { declare readonly _serviceBrand: undefined; readonly windows = constObservable([]); diff --git a/src/vs/workbench/contrib/sessions/browser/chat/modelPicker.ts b/src/vs/workbench/contrib/sessions/browser/chat/modelPicker.ts index 578791fff05a52..e9bd14d32a18a4 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/modelPicker.ts +++ b/src/vs/workbench/contrib/sessions/browser/chat/modelPicker.ts @@ -311,7 +311,7 @@ export function registerModelPickerAction(): IDisposable { order: 1, // Hidden on phone when the active provider supplies a combined // mode + model picker instead (see MobileChatInputConfigPicker). - when: ContextKeyExpr.or(IsPhoneLayoutContext.negate(), SessionUsesCombinedConfigPickerContext.negate()), + when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.or(IsPhoneLayoutContext.negate(), SessionUsesCombinedConfigPickerContext.negate())), }], }); } diff --git a/src/vs/workbench/contrib/sessions/browser/chat/sharedChatInputComposerToolbar.ts b/src/vs/workbench/contrib/sessions/browser/chat/sharedChatInputComposerToolbar.ts index 8ba4ae2624c294..2b49c79769231e 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/sharedChatInputComposerToolbar.ts +++ b/src/vs/workbench/contrib/sessions/browser/chat/sharedChatInputComposerToolbar.ts @@ -17,6 +17,8 @@ import { Action2, MenuRegistry, registerAction2 } from '../../../../../platform/ import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { CHAT_ATTACH_CONTEXT_ACTION_ID } from '../../../chat/common/constants.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ChatContextKeys } from '../../../chat/common/actions/chatContextKeys.js'; import { Menus } from '../menus.js'; import { getLabeledPickerResponsiveItems } from './newChatComposerInput.js'; import { createNewSessionControlToolbar } from './newSessionConfigToolbars.js'; @@ -125,6 +127,7 @@ export function registerSharedChatInputComposerToolbar(): IDisposable { command: { id: CHAT_ATTACH_CONTEXT_ACTION_ID, title: localize('sessionsSharedChatInput.addContext', "Add Context..."), icon: Codicon.addCompact }, group: 'navigation', order: -2, + when: ChatContextKeys.enabled, })); for (const [id, title, busy] of [ @@ -137,7 +140,7 @@ export function registerSharedChatInputComposerToolbar(): IDisposable { id, title, f1: false, - menu: { id: Menus.NewSessionChatInputToolbar, group: 'navigation', order: 100, when: SharedChatInputComposerBusyContext.isEqualTo(busy) }, + menu: { id: Menus.NewSessionChatInputToolbar, group: 'navigation', order: 100, when: ContextKeyExpr.and(ChatContextKeys.enabled, SharedChatInputComposerBusyContext.isEqualTo(busy)) }, }); } override run(): void { /* progress only */ } @@ -150,7 +153,7 @@ export function registerSharedChatInputComposerToolbar(): IDisposable { id: COMPOSER_SESSION_CONTROLS_ACTION_ID, title: localize2('sessionsSharedChatInput.sessionControls', "Session Controls"), f1: false, - menu: { id: Menus.NewSessionChatInputToolbar, group: 'navigation', order: 0 }, + menu: { id: Menus.NewSessionChatInputToolbar, group: 'navigation', order: 0, when: ChatContextKeys.enabled }, }); } override run(): void { /* rendered by the action view item */ } diff --git a/src/vs/workbench/contrib/sessions/browser/closedItemHistory.ts b/src/vs/workbench/contrib/sessions/browser/closedItemHistory.ts index aa4be66bdbf4f0..358d1ccd480cf9 100644 --- a/src/vs/workbench/contrib/sessions/browser/closedItemHistory.ts +++ b/src/vs/workbench/contrib/sessions/browser/closedItemHistory.ts @@ -118,7 +118,9 @@ export class ClosedItemHistory extends Disposable { // The recorded session may be a stale incarnation that left the grid with // its slot, and a provider can drop it from its catalog without ever // firing `onDidDeleteSession`. - const session = this._sessionsManagementService.getSessions().find(s => s.sessionId === item.session.sessionId); + // Also a session outside a provider's listing scope, such as of another folder in an editor window + const found = this._sessionsManagementService.getSession(item.session.resource); + const session = found?.sessionId === item.session.sessionId ? found : undefined; if (!session) { return; } diff --git a/src/vs/workbench/contrib/sessions/browser/sessionAttentionService.ts b/src/vs/workbench/contrib/sessions/browser/sessionAttentionService.ts index 7a00fc3c7f3e45..e2829205876143 100644 --- a/src/vs/workbench/contrib/sessions/browser/sessionAttentionService.ts +++ b/src/vs/workbench/contrib/sessions/browser/sessionAttentionService.ts @@ -12,6 +12,7 @@ import { ResourceMap } from '../../../../base/common/map.js'; import { autorun, derived, derivedOpts, IObservable, IReader, IReaderWithStore, mapObservableArrayCached, observableSignal, observableSignalFromEvent, observableValue } from '../../../../base/common/observable.js'; import { getComparisonKey } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { isCopilotReviewAuthor } from '../../../../platform/github/common/pullRequestFeedback.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IChatModelReference, IChatService, IChatToolInvocation } from '../../chat/common/chatService/chatService.js'; @@ -523,3 +524,6 @@ function parseTimestamp(value: string | undefined): number | undefined { const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : undefined; } + +// Registered here, once, for every window that loads this module +registerSingleton(ISessionAttentionService, SessionAttentionService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/sessions/providers/agentHost/browser/agentHostAgentPicker.ts b/src/vs/workbench/contrib/sessions/providers/agentHost/browser/agentHostAgentPicker.ts index a48f3171d17fbd..217113030873db 100644 --- a/src/vs/workbench/contrib/sessions/providers/agentHost/browser/agentHostAgentPicker.ts +++ b/src/vs/workbench/contrib/sessions/providers/agentHost/browser/agentHostAgentPicker.ts @@ -127,7 +127,7 @@ export class AgentHostAgentPickerAction extends Action2 { group: 'navigation', order: -1, when: ContextKeyExpr.and( - IsActiveSessionAgentHost, IsPhoneLayoutContext.negate(), ChatContextKeys.inAutomationsDialog.negate(), + ChatContextKeys.enabled, IsActiveSessionAgentHost, IsPhoneLayoutContext.negate(), ChatContextKeys.inAutomationsDialog.negate(), ), }, { id: Menus.AutomationsDialogInputToolbar, @@ -142,7 +142,7 @@ export class AgentHostAgentPickerAction extends Action2 { group: 'navigation', order: 0, // Hide the agent picker while a delegation (continue in) target is pending. - when: ContextKeyExpr.and(ChatContextKeyExprs.isAgentHostSession, IsSessionsWindowContext, InSessionHostScopeContext, IsPhoneLayoutContext.negate(), ChatContextKeys.hasPendingDelegationTarget.negate()), + when: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeyExprs.isAgentHostSession, IsSessionsWindowContext, InSessionHostScopeContext, IsPhoneLayoutContext.negate(), ChatContextKeys.hasPendingDelegationTarget.negate()), }], }); } @@ -474,7 +474,7 @@ export function registerAgentHostAgentPickerActions(): IDisposable { store.add(KeybindingsRegistry.registerCommandAndKeybindingRule({ id: OPEN_AGENT_HOST_AGENT_PICKER_COMMAND_ID, weight: KeybindingWeight.EditorContrib + 1, - when: AgentHostAgentPickerKeybindingContext, + when: ContextKeyExpr.and(ChatContextKeys.enabled, AgentHostAgentPickerKeybindingContext), primary: KeyMod.CtrlCmd | KeyCode.Period, handler: () => openActiveAgentHostAgentPicker(), })); diff --git a/src/vs/workbench/contrib/sessions/providers/agentHost/browser/agentHostSessionConfigPicker.ts b/src/vs/workbench/contrib/sessions/providers/agentHost/browser/agentHostSessionConfigPicker.ts index e173aaa9d4bcfd..3c38a5e264224b 100644 --- a/src/vs/workbench/contrib/sessions/providers/agentHost/browser/agentHostSessionConfigPicker.ts +++ b/src/vs/workbench/contrib/sessions/providers/agentHost/browser/agentHostSessionConfigPicker.ts @@ -2144,7 +2144,7 @@ export class NewSessionApprovePickerAction extends Action2 { id: Menus.NewSessionControl, group: 'navigation', order: 1, - when: ContextKeyExpr.or(IsActiveSessionLocalAgentHost, IsActiveSessionRemoteAgentHost), + when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.or(IsActiveSessionLocalAgentHost, IsActiveSessionRemoteAgentHost)), }], }); } @@ -2164,7 +2164,7 @@ export class NewSessionPermissionModePickerAction extends Action2 { id: Menus.NewSessionControl, group: 'navigation', order: 2, - when: ContextKeyExpr.or(IsActiveSessionLocalAgentHost, IsActiveSessionRemoteAgentHost), + when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.or(IsActiveSessionLocalAgentHost, IsActiveSessionRemoteAgentHost)), }], }); } @@ -2190,7 +2190,7 @@ export class NewSessionCodexApprovalsPickerAction extends Action2 { id: Menus.NewSessionControl, group: 'navigation', order: 3, - when: ContextKeyExpr.or(IsActiveSessionLocalAgentHost, IsActiveSessionRemoteAgentHost), + when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.or(IsActiveSessionLocalAgentHost, IsActiveSessionRemoteAgentHost)), }], }); } @@ -2216,6 +2216,7 @@ export class NewSessionModePickerAction extends Action2 { // this picker with a unified mode + model bottom sheet, so // gate this desktop-only Action out of phone layouts. when: ContextKeyExpr.and( + ChatContextKeys.enabled, ContextKeyExpr.or(IsActiveSessionLocalAgentHost, IsActiveSessionRemoteAgentHost), IsPhoneLayoutContext.negate(), ), @@ -2241,12 +2242,12 @@ export class RunningSessionConfigPickerAction extends Action2 { id: MenuId.ChatInput, group: 'navigation', order: 0.2, - when: ContextKeyExpr.and(ChatContextKeyExprs.isAgentHostSession, InSessionHostScopeContext, ExperimentalSessionComposerLayout), + when: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeyExprs.isAgentHostSession, InSessionHostScopeContext, ExperimentalSessionComposerLayout), }, { id: MenuId.ChatInputSecondary, group: 'navigation', order: 10, - when: ContextKeyExpr.and(ChatContextKeyExprs.isAgentHostSession, InSessionHostScopeContext, LegacySessionComposerLayout), + when: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeyExprs.isAgentHostSession, InSessionHostScopeContext, LegacySessionComposerLayout), }], }); } @@ -2266,12 +2267,12 @@ export class RunningSessionPermissionModePickerAction extends Action2 { id: MenuId.ChatInput, group: 'navigation', order: 0.3, - when: ContextKeyExpr.and(ChatContextKeyExprs.isAgentHostSession, InSessionHostScopeContext, ExperimentalSessionComposerLayout), + when: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeyExprs.isAgentHostSession, InSessionHostScopeContext, ExperimentalSessionComposerLayout), }, { id: MenuId.ChatInputSecondary, group: 'navigation', order: 11, - when: ContextKeyExpr.and(ChatContextKeyExprs.isAgentHostSession, InSessionHostScopeContext, LegacySessionComposerLayout), + when: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeyExprs.isAgentHostSession, InSessionHostScopeContext, LegacySessionComposerLayout), }], }); } @@ -2296,12 +2297,12 @@ export class RunningSessionCodexApprovalsPickerAction extends Action2 { id: MenuId.ChatInput, group: 'navigation', order: 0.4, - when: ContextKeyExpr.and(ChatContextKeyExprs.isAgentHostSession, InSessionHostScopeContext, ExperimentalSessionComposerLayout), + when: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeyExprs.isAgentHostSession, InSessionHostScopeContext, ExperimentalSessionComposerLayout), }, { id: MenuId.ChatInputSecondary, group: 'navigation', order: 12, - when: ContextKeyExpr.and(ChatContextKeyExprs.isAgentHostSession, InSessionHostScopeContext, LegacySessionComposerLayout), + when: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeyExprs.isAgentHostSession, InSessionHostScopeContext, LegacySessionComposerLayout), }], }); } @@ -2325,6 +2326,7 @@ export class RunningSessionModePickerAction extends Action2 { group: 'navigation', order: 0.1, when: ContextKeyExpr.and( + ChatContextKeys.enabled, ChatContextKeyExprs.isAgentHostSession, InSessionHostScopeContext, ChatContextKeys.hasPendingDelegationTarget.negate(), @@ -2336,6 +2338,7 @@ export class RunningSessionModePickerAction extends Action2 { order: 9, // Hide the agent mode picker while a delegation (continue in) target is pending. when: ContextKeyExpr.and( + ChatContextKeys.enabled, ChatContextKeyExprs.isAgentHostSession, InSessionHostScopeContext, ChatContextKeys.hasPendingDelegationTarget.negate(), diff --git a/src/vs/workbench/contrib/sessions/providers/agentHost/browser/agentSessionSettingsFileSystemProvider.ts b/src/vs/workbench/contrib/sessions/providers/agentHost/browser/agentSessionSettingsFileSystemProvider.ts index e9c4f020cb0888..b53feea71a8376 100644 --- a/src/vs/workbench/contrib/sessions/providers/agentHost/browser/agentSessionSettingsFileSystemProvider.ts +++ b/src/vs/workbench/contrib/sessions/providers/agentHost/browser/agentSessionSettingsFileSystemProvider.ts @@ -154,7 +154,7 @@ export class AgentSessionSettingsFileSystemProvider extends AbstractSessionsAgen } protected _ensureSchemaRegistered(provider: IAgentHostSessionsProvider, ctx: ISessionSettingsContext): void { - const session = provider.getSessions().find(s => s.sessionId === ctx.sessionId); + const session = (provider.getAllSessions?.() ?? provider.getSessions()).find(s => s.sessionId === ctx.sessionId); if (session) { this._schemaRegistrar.ensureRegistered(session); } @@ -216,7 +216,7 @@ export class AgentSessionSettingsSchemaRegistrar extends AbstractMultiProviderAg ): IDisposable { const store = new DisposableStore(); store.add(provider.onDidChangeSessionConfig(sessionId => { - const session = provider.getSessions().find(s => s.sessionId === sessionId); + const session = (provider.getAllSessions?.() ?? provider.getSessions()).find(s => s.sessionId === sessionId); if (session) { onChanged(session); } From a81d5209b9584329e1c1009adbaf5f74a8a4415c Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 03:26:46 +0100 Subject: [PATCH 004/165] Sessions: group a composer's folders into Folder and Worktrees sections, with New Worktree first A composer restricted to a window's folders (an editor window's composer) can now say, for each folder, whether it is a folder of the window or a worktree of it, and whether the window's folder is there now (IAllowedFolderPresentation.kind and .current). Once any folder has a kind, the workspace picker offers New Worktree first, then a "Folder" (or "Folders") section and a "Worktrees" section. Each section lists its current folder first, described as "Current", and then the others in their order. The selected folder keeps its check wherever it shows. Without kinds, the picker lists the folders as before. Nothing on main sets them yet, and the Agents window's picker, which has no allowed folders, is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/sessionWorkspacePicker.test.ts | 25 ++++ .../browser/chat/sessionWorkspacePicker.ts | 117 +++++++++++++----- .../browser/chatEditor/chatWindowComposer.ts | 8 +- .../sessions/browser/parts/chatView.ts | 4 +- 4 files changed, 115 insertions(+), 39 deletions(-) diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index ced4d3d7a81c34..209a3436d559be 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -8919,6 +8919,31 @@ suite('WorkspacePicker - Allowed folders', () => { }); }); + test('with sections for the window\'s folder and its worktrees, offers New Worktree first and each section\'s current folder first, described as current', async () => { + const noWorkspace = { selected: false }; + let current = '/local/vscode.worktrees/b'; + const { picker } = createPicker(['/local/vscode.worktrees/b', '/local/vscode', '/local/vscode.worktrees/a'], noWorkspace, { + getAllowedFolderPresentation: folderUri => folderUri.path === '/local/vscode' + ? { kind: 'folder', current: current === folderUri.path } + : { label: folderUri.path.split('/').pop(), description: `feature-${folderUri.path.split('/').pop()}`, icon: Codicon.gitBranchCheckout, kind: 'worktree', current: current === folderUri.path }, + newWorktree: { isAvailable: () => true, isSelected: () => false, setSelected: () => { } }, + }); + picker.setSelectedWorkspace(URI.file('/local/vscode.worktrees/b'), { fireEvent: false }); + const items = () => picker.getItems().map(item => item.kind === ActionListItemKind.Separator + ? `---${item.label ? ` ${item.label}` : ''}` + : `${item.label}${item.description ? ` (${item.description})` : ''}${item.item?.checked ? ' *' : ''}`); + + const atWorktree = items(); + current = '/local/vscode'; + await picker.dispatchFolder(URI.file('/local/vscode'), 'local-1'); + const atFolder = items(); + + assert.deepStrictEqual({ atWorktree, atFolder }, { + atWorktree: ['Chat', '---', 'New Worktree', '--- Folder', 'local/vscode', '--- Worktrees', 'b (Current) *', 'a (feature-a)'], + atFolder: ['Chat', '---', 'New Worktree', '--- Folder', 'local/vscode (Current) *', '--- Worktrees', 'b (feature-b)', 'a (feature-a)'], + }); + }); + test('picking another folder after New Worktree selects it, also where leaving New Worktree selects the first folder again as its configuration changes', async () => { const newWorktree = { selected: true }; const { picker } = createPicker(['/local/a', '/local/b'], undefined, { diff --git a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts index 41a82809be3322..c8c1e201c70e34 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts +++ b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts @@ -152,11 +152,26 @@ export interface IWorkspacePickerOptions { */ readonly allowedFolders?: IObservable; /** How the picker shows an allowed folder, such as a linked checkout of a window's folder: by its name and branch. */ - readonly getAllowedFolderPresentation?: (folderUri: URI) => IFolderLocation | undefined; + readonly getAllowedFolderPresentation?: (folderUri: URI) => IAllowedFolderPresentation | undefined; /** Offers New Worktree after the {@link allowedFolders}: a session that works in a new worktree of the selected folder. */ readonly newWorktree?: IWorkspacePickerNewWorktreeOption; } +/** + * How a picker restricted to its allowed folders shows one of them ({@link IWorkspacePickerOptions.getAllowedFolderPresentation}), + * such as a linked checkout of a window's folder. A presentation with a label also gives the folder's description. + */ +export interface IAllowedFolderPresentation extends Partial> { + /** + * The section the folder shows in: Folder for a folder of the window, such as its primary checkout, and Worktrees for a + * linked checkout of it. Once any allowed folder has one, the picker offers New Worktree first and then the two sections; + * otherwise it shows the folders in their order, without sections. + */ + readonly kind?: 'folder' | 'worktree'; + /** Whether the window's folder is at this folder now: it shows first in its section, described as current. */ + readonly current?: boolean; +} + /** The New Worktree choice of a picker restricted to its allowed folders ({@link IWorkspacePickerOptions.newWorktree}). */ export interface IWorkspacePickerNewWorktreeOption { /** Whether the session of the selected folder can work in a new worktree. */ @@ -2283,32 +2298,41 @@ export class WorkspacePicker extends Disposable { /** Puts New Worktree after `items`, when the composer offers it for the selected folder. */ private _withNewWorktreeItem(items: IActionListItem[]): IActionListItem[] { - const newWorktree = this.options.newWorktree; - const newWorktreeSelected = this._isNewWorktreeSelected(); - if (newWorktree && this._directPickerAttachesContext !== true && this._selectedFolderUri && !this.isNoWorkspaceSelected() && newWorktree.isAvailable()) { + const newWorktreeItem = this._getNewWorktreeItem(); + if (newWorktreeItem) { if (items.length) { items.push({ kind: ActionListItemKind.Separator, label: '' }); } - items.push({ - kind: ActionListItemKind.Action, - label: localize('workspacePicker.newWorktree', "New Worktree"), - description: undefined, - ariaDescription: localize('workspacePicker.newWorktreeDescription', "Start the session in a new worktree of the selected folder."), - group: { title: '', icon: SESSION_WORKTREE_ICON }, - item: { - id: 'workspacePicker.newWorktree', - checked: newWorktreeSelected || undefined, - run: () => { - newWorktree.setSelected(true); - this._updateTriggerLabel(); - this._onDidChangeSelection.fire(); - }, - }, - }); + items.push(newWorktreeItem); } return items; } + /** The New Worktree choice, when the composer offers it for the selected folder. */ + private _getNewWorktreeItem(): IActionListItem | undefined { + const newWorktree = this.options.newWorktree; + const newWorktreeSelected = this._isNewWorktreeSelected(); + if (!newWorktree || this._directPickerAttachesContext === true || !this._selectedFolderUri || this.isNoWorkspaceSelected() || !newWorktree.isAvailable()) { + return undefined; + } + return { + kind: ActionListItemKind.Action, + label: localize('workspacePicker.newWorktree', "New Worktree"), + description: undefined, + ariaDescription: localize('workspacePicker.newWorktreeDescription', "Start the session in a new worktree of the selected folder."), + group: { title: '', icon: SESSION_WORKTREE_ICON }, + item: { + id: 'workspacePicker.newWorktree', + checked: newWorktreeSelected || undefined, + run: () => { + newWorktree.setSelected(true); + this._updateTriggerLabel(); + this._onDidChangeSelection.fire(); + }, + }, + }; + } + /** Puts the No Workspace choice before `items`, when the composer offers one. */ private _withNoWorkspaceItem(items: IActionListItem[]): IActionListItem[] { const noWorkspaceOption = this._getNoWorkspaceOption(); @@ -2839,7 +2863,27 @@ export class WorkspacePicker extends Disposable { */ protected _buildRestrictedItems(): IActionListItem[] | undefined { const allowedFolders = this._getAllowedFolders(); - return allowedFolders && this._withSelectionMarks(this._withNewWorktreeItem(this._withNoWorkspaceItem(this._buildAllowedFolderItems(allowedFolders)))); + if (!allowedFolders) { + return undefined; + } + const folders = this._buildAllowedFolderItems(allowedFolders); + if (!folders.some(folder => folder.kind)) { + return this._withSelectionMarks(this._withNewWorktreeItem(this._withNoWorkspaceItem(folders.map(folder => folder.item)))); + } + + // New Worktree first, then the window's folders and the worktrees, each in a section with the current one first + const newWorktreeItem = this._getNewWorktreeItem(); + const items = this._withNoWorkspaceItem(newWorktreeItem ? [newWorktreeItem] : []); + const section = (title: string, sectionFolders: readonly { readonly item: IActionListItem; readonly current: boolean }[]) => { + if (sectionFolders.length) { + items.push({ kind: ActionListItemKind.Separator, label: title }); + items.push(...sectionFolders.filter(folder => folder.current).map(folder => folder.item), ...sectionFolders.filter(folder => !folder.current).map(folder => folder.item)); + } + }; + const windowFolders = folders.filter(folder => folder.kind !== 'worktree'); + section(windowFolders.length > 1 ? localize('workspacePicker.folders', "Folders") : localize('workspacePicker.folder', "Folder"), windowFolders); + section(localize('workspacePicker.worktrees', "Worktrees"), folders.filter(folder => folder.kind === 'worktree')); + return this._withSelectionMarks(items); } /** The folders the picker is restricted to; none restrict it. */ @@ -2859,9 +2903,9 @@ export class WorkspacePicker extends Disposable { return !!allowedFolders && allowedFolders.length <= 1 && !!this._selectedFolderUri && !this._getNoWorkspaceOption() && !this.options.newWorktree?.isAvailable(); } - /** The items of a picker restricted to its allowed folders: one for each folder a provider resolves. */ - private _buildAllowedFolderItems(allowedFolders: readonly URI[]): IActionListItem[] { - const items: IActionListItem[] = []; + /** The items of a picker restricted to its allowed folders, one for each folder a provider resolves, with each one's section. */ + private _buildAllowedFolderItems(allowedFolders: readonly URI[]): { readonly item: IActionListItem; readonly kind: IAllowedFolderPresentation['kind']; readonly current: boolean }[] { + const items: { readonly item: IActionListItem; readonly kind: IAllowedFolderPresentation['kind']; readonly current: boolean }[] = []; const newWorktreeSelected = this._isNewWorktreeSelected(); for (const folderUri of allowedFolders) { const resolved = this._resolveFolder(folderUri, this._isSelectedFolder(folderUri) ? this._selectedResolved?.providerId : undefined); @@ -2869,17 +2913,24 @@ export class WorkspacePicker extends Disposable { continue; } const presentation = this.options.getAllowedFolderPresentation?.(folderUri); + const current = !!presentation?.current; items.push({ - kind: ActionListItemKind.Action, - label: presentation?.label ?? resolved.workspace.label, - description: presentation ? presentation.description : resolved.workspace.description, - group: { title: '', icon: presentation?.icon ?? this._getWorkspaceIcon(resolved.workspace) }, - disabled: this._isProviderUnavailable(resolved.providerId), + kind: presentation?.kind, + current, item: { - id: `workspacePicker.workspace.${this.uriIdentityService.extUri.getComparisonKey(folderUri)}`, - folderUri, - providerId: resolved.providerId, - checked: (!newWorktreeSelected && this._isSelectedFolder(folderUri)) || undefined, + kind: ActionListItemKind.Action, + label: presentation?.label ?? resolved.workspace.label, + description: current + ? localize('workspacePicker.current', "Current") + : presentation?.label !== undefined ? presentation.description : resolved.workspace.description, + group: { title: '', icon: presentation?.icon ?? this._getWorkspaceIcon(resolved.workspace) }, + disabled: this._isProviderUnavailable(resolved.providerId), + item: { + id: `workspacePicker.workspace.${this.uriIdentityService.extUri.getComparisonKey(folderUri)}`, + folderUri, + providerId: resolved.providerId, + checked: (!newWorktreeSelected && this._isSelectedFolder(folderUri)) || undefined, + }, }, }); } diff --git a/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts b/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts index b2d6dd50dc3b06..d41ee05185b209 100644 --- a/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts +++ b/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts @@ -11,7 +11,7 @@ import { derived, IObservable, observableValue } from '../../../../../base/commo import { parse, stringify } from '../../../../../base/common/marshalling.js'; import { extUriBiasedIgnorePathCase } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; -import { IFolderLocation } from '../../../../../platform/workspace/common/folderLocations.js'; +import { IAllowedFolderPresentation } from '../chat/sessionWorkspacePicker.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { localize } from '../../../../../nls.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -189,7 +189,7 @@ export interface IChatWindowComposerInit { */ readonly allowedFolders?: IObservable; /** How it shows one of its {@link allowedFolders}, such as a linked checkout of the window's folder. */ - readonly getFolderPresentation?: (folderUri: URI) => IFolderLocation | undefined; + readonly getFolderPresentation?: (folderUri: URI) => IAllowedFolderPresentation | undefined; } /** @@ -234,7 +234,7 @@ export class ChatWindowComposerModel extends Disposable implements INewSessionDr readonly groupId: string | undefined; readonly allowedFolders: IObservable | undefined; - private readonly _getFolderPresentation: ((folderUri: URI) => IFolderLocation | undefined) | undefined; + private readonly _getFolderPresentation: ((folderUri: URI) => IAllowedFolderPresentation | undefined) | undefined; constructor( readonly resource: URI, @@ -295,7 +295,7 @@ export class ChatWindowComposerModel extends Disposable implements INewSessionDr } } - getFolderPresentation(folderUri: URI): IFolderLocation | undefined { + getFolderPresentation(folderUri: URI): IAllowedFolderPresentation | undefined { return this._getFolderPresentation?.(folderUri); } diff --git a/src/vs/workbench/contrib/sessions/browser/parts/chatView.ts b/src/vs/workbench/contrib/sessions/browser/parts/chatView.ts index 42a93641c46c34..db979c7b6db0d0 100644 --- a/src/vs/workbench/contrib/sessions/browser/parts/chatView.ts +++ b/src/vs/workbench/contrib/sessions/browser/parts/chatView.ts @@ -11,7 +11,7 @@ import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js import { IAction } from '../../../../../base/common/actions.js'; import { constObservable, IObservable } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; -import { IFolderLocation } from '../../../../../platform/workspace/common/folderLocations.js'; +import { IAllowedFolderPresentation } from '../chat/sessionWorkspacePicker.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { IEditorOptions } from '../../../../../editor/common/config/editorOptions.js'; import { IAgentsWindowDraft } from '../../../../../platform/window/common/window.js'; @@ -117,7 +117,7 @@ export interface INewSessionDraftHost { */ readonly allowedFolders?: IObservable; /** How its workspace picker shows one of its {@link allowedFolders}, such as a linked checkout of a window's folder. */ - getFolderPresentation?(folderUri: URI): IFolderLocation | undefined; + getFolderPresentation?(folderUri: URI): IAllowedFolderPresentation | undefined; /** Opens a session the composer started in the background, in the composer's own window. */ openStartedSession(session: ISession): Promise; /** From 71a1492c1e82a6793d93b9d9d22cacf95ed011f0 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 03:58:00 +0100 Subject: [PATCH 005/165] chat: let a view that shows in place of the Chat view drive its sessions actions and New Chat - IChatViewSessionsControl: what the actions of the Chat view's sessions toolbar act on. The Chat view implements it as it is; the actions take it instead of the Chat view itself. - IChatViewHost.newChat: a host that replaces the Chat view can start New Chat of the Chat view itself, such as from its title; the Chat view's own host does not, so New Chat stays as it is. - Context keys for a Chat view that shows other sessions (chatViewSessionsListActive, chatViewSessionsInChatPart), which hide the filter and the side pane of the sessions toolbar that do not apply then, and a generic auxiliaryBarMaximizeHidden that hides Maximize of the secondary side bar. All are off unless set. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../parts/auxiliarybar/auxiliaryBarActions.ts | 3 ++- src/vs/workbench/common/contextkeys.ts | 1 + .../chat/browser/actions/chatNewActions.ts | 7 +++++++ .../agentSessions/agentSessions.contribution.ts | 6 ++++++ .../chat/browser/agentSessions/agentSessions.ts | 12 ++++++++++++ .../agentSessions/agentSessionsActions.ts | 16 ++++++++-------- .../contrib/chat/browser/chatViewHost.ts | 7 +++++++ .../widgetHosts/viewPane/chatViewHostService.ts | 4 ++++ .../browser/widgetHosts/viewPane/chatViewPane.ts | 4 ++-- .../chat/common/actions/chatContextKeys.ts | 2 ++ .../viewPane/chatViewHostService.test.ts | 14 ++++++++++++++ 11 files changed, 65 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts index 19346c2741b067..afe55c3142ac13 100644 --- a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts +++ b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts @@ -9,7 +9,7 @@ import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../plat import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; -import { AuxiliaryBarMaximizedContext, AuxiliaryBarVisibleContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, SecondarySideBarVisibleContext } from '../../../common/contextkeys.js'; +import { AuxiliaryBarMaximizeHiddenContext, AuxiliaryBarMaximizedContext, AuxiliaryBarVisibleContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, SecondarySideBarVisibleContext } from '../../../common/contextkeys.js'; import { ViewContainerLocation, ViewContainerLocationToString } from '../../../common/views.js'; import { ActivityBarPosition, IWorkbenchLayoutService, LayoutSettings, Parts } from '../../../services/layout/browser/layoutService.js'; import { IPaneCompositePartService } from '../../../services/panecomposite/browser/panecomposite.js'; @@ -277,6 +277,7 @@ class ToggleMaximizedAuxiliaryBar extends Action2 { id: MenuId.AuxiliaryBarTitle, group: 'navigation', order: 1, + when: AuxiliaryBarMaximizeHiddenContext.negate(), } }); } diff --git a/src/vs/workbench/common/contextkeys.ts b/src/vs/workbench/common/contextkeys.ts index b27303d2c7a127..55d56f877af882 100644 --- a/src/vs/workbench/common/contextkeys.ts +++ b/src/vs/workbench/common/contextkeys.ts @@ -176,6 +176,7 @@ export const NotificationsToastsVisibleContext = new RawContextKey('not //#region < --- Auxiliary Bar --- > export const ActiveAuxiliaryContext = new RawContextKey('activeAuxiliary', '', localize('activeAuxiliary', "The identifier of the active auxiliary panel")); +export const AuxiliaryBarMaximizeHiddenContext = new RawContextKey('auxiliaryBarMaximizeHidden', false, localize('auxiliaryBarMaximizeHidden', "Whether the active view container of the secondary side bar does not offer to maximize it")); export const AuxiliaryBarFocusContext = new RawContextKey('auxiliaryBarFocus', false, localize('auxiliaryBarFocus', "Whether the auxiliary bar has keyboard focus")); export const AuxiliaryBarVisibleContext = new RawContextKey('auxiliaryBarVisible', false, localize('auxiliaryBarVisible', "Whether the auxiliary bar is visible")); export const SecondarySideBarVisibleContext = new RawContextKey('secondarySideBarVisible', false, localize('secondarySideBarVisible', "Whether the layout surface representing the secondary side bar is visible")); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts index f98ca54fa580af..409b558697dfe0 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts @@ -25,6 +25,8 @@ import { IVoiceSessionController } from '../voiceClient/voiceSessionController.j import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.js'; import { EditingSessionAction, EditingSessionActionContext, getEditingSessionContext } from '../chatEditing/chatEditingActions.js'; import { ACTION_ID_NEW_CHAT, ACTION_ID_NEW_EDIT_SESSION, CHAT_CATEGORY, clearChatSessionPreservingType, handleCurrentEditingSession } from './chatActions.js'; +import { isChatViewTitleActionContext } from '../../common/actions/chatActions.js'; +import { IChatViewHostService } from '../chatViewHost.js'; import { clearChatEditor } from './chatClear.js'; import { AgentSessionProviders, AgentSessionsViewerOrientation } from '../agentSessions/agentSessions.js'; @@ -107,6 +109,11 @@ export class NewChatAction extends Action2 { const actionContext = unwrapChatContextMenuActionContext(args[0]); const executeCommandContext = isNewEditSessionActionContext(actionContext) ? actionContext : undefined; + // For the Chat view, such as from its title, a host that replaces it may start the new chat itself + if ((!actionContext || isChatViewTitleActionContext(actionContext)) && accessor.get(IChatViewHostService).newChat()) { + return; + } + // Context from toolbar or lastFocusedWidget const context = getEditingSessionContext(accessor, [actionContext, ...args.slice(1)]); await runNewChatAction(accessor, context, executeCommandContext); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts index 9fd8154696b322..190dfac72a4403 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts @@ -102,6 +102,8 @@ MenuRegistry.appendMenuItem(MenuId.AgentSessionsToolbar, { group: 'navigation', order: 3, icon: Codicon.filter, + // Not for the sessions of the Agents window, which this filter does not apply to + when: ChatContextKeys.chatViewSessionsListActive.negate(), } satisfies ISubmenuItem); MenuRegistry.appendMenuItem(MenuId.AgentSessionsToolbar, { @@ -114,6 +116,7 @@ MenuRegistry.appendMenuItem(MenuId.AgentSessionsToolbar, { order: 5, when: ContextKeyExpr.and( ChatContextKeys.agentSessionsViewerOrientation.isEqualTo(AgentSessionsViewerOrientation.Stacked), + ChatContextKeys.chatViewSessionsInChatPart.negate(), ChatContextKeys.agentSessionsViewerPosition.isEqualTo(AgentSessionsViewerPosition.Right) ) }); @@ -128,6 +131,7 @@ MenuRegistry.appendMenuItem(MenuId.AgentSessionsToolbar, { order: 5, when: ContextKeyExpr.and( ChatContextKeys.agentSessionsViewerOrientation.isEqualTo(AgentSessionsViewerOrientation.Stacked), + ChatContextKeys.chatViewSessionsInChatPart.negate(), ChatContextKeys.agentSessionsViewerPosition.isEqualTo(AgentSessionsViewerPosition.Left) ) }); @@ -142,6 +146,7 @@ MenuRegistry.appendMenuItem(MenuId.AgentSessionsToolbar, { order: 5, when: ContextKeyExpr.and( ChatContextKeys.agentSessionsViewerOrientation.isEqualTo(AgentSessionsViewerOrientation.SideBySide), + ChatContextKeys.chatViewSessionsInChatPart.negate(), ChatContextKeys.agentSessionsViewerPosition.isEqualTo(AgentSessionsViewerPosition.Right) ) }); @@ -156,6 +161,7 @@ MenuRegistry.appendMenuItem(MenuId.AgentSessionsToolbar, { order: 5, when: ContextKeyExpr.and( ChatContextKeys.agentSessionsViewerOrientation.isEqualTo(AgentSessionsViewerOrientation.SideBySide), + ChatContextKeys.chatViewSessionsInChatPart.negate(), ChatContextKeys.agentSessionsViewerPosition.isEqualTo(AgentSessionsViewerPosition.Left) ) }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index 1eb7517cf6b2db..7d396f3288fe01 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -226,6 +226,18 @@ export interface IAgentSessionsControl { collapseAllSections(): void; } +/** + * The sessions of the Chat view, which the actions of its sessions toolbar act on: those of the Chat view, or of a view that + * shows in its place. + */ +export interface IChatViewSessionsControl { + readonly agentSessionsControl: IAgentSessionsControl | undefined; + getSessionsViewerOrientation(): AgentSessionsViewerOrientation; + updateConfiguredSessionsViewerOrientation(orientation: 'stacked' | 'sideBySide' | unknown): void; + getLastDimensions(orientation: AgentSessionsViewerOrientation): { height: number; width: number } | undefined; + focusSessions(): boolean; +} + export const agentSessionReadIndicatorForeground = registerColor( 'agentSessionReadIndicator.foreground', { dark: transparent(foreground, 0.2), light: transparent(foreground, 0.2), hcDark: null, hcLight: null }, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts index 18d32dd5395bd0..be2c7f4075a2a0 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts @@ -9,7 +9,7 @@ import { AgentSessionSection, IAgentSession, IAgentSessionSection, IMarshalledAg import { Action2, MenuId, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; -import { AGENT_SESSION_DELETE_ACTION_ID, AGENT_SESSION_RENAME_ACTION_ID, AgentSessionChatContextMenu, AgentSessionChatToolbar, AgentSessionProviders, AgentSessionsViewerOrientation, IAgentSessionsControl } from './agentSessions.js'; +import { AGENT_SESSION_DELETE_ACTION_ID, AGENT_SESSION_RENAME_ACTION_ID, AgentSessionChatContextMenu, AgentSessionChatToolbar, AgentSessionProviders, AgentSessionsViewerOrientation, IAgentSessionsControl, IChatViewSessionsControl } from './agentSessions.js'; import { IChatService } from '../../common/chatService/chatService.js'; import { IChatSessionsService } from '../../common/chatSessionsService.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; @@ -18,7 +18,7 @@ import { getChatSessionType, LocalChatSessionUri } from '../../common/model/chat import { IChatEditorOptions } from '../widgetHosts/editor/chatEditor.js'; import { ChatViewId, IChatWidgetService } from '../chat.js'; import { ACTIVE_GROUP, AUX_WINDOW_GROUP, PreferredGroup, SIDE_GROUP } from '../../../../services/editor/common/editorService.js'; -import { IViewDescriptorService, ViewContainerLocation } from '../../../../common/views.js'; +import { IView, IViewDescriptorService, ViewContainerLocation } from '../../../../common/views.js'; import { IWorkbenchLayoutService, Position } from '../../../../services/layout/browser/layoutService.js'; import { IAgentSessionsService } from './agentSessionsService.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -1142,7 +1142,7 @@ export class RefreshAgentSessionsViewerAction extends Action2 { } override run(accessor: ServicesAccessor, agentSessionsControl?: IAgentSessionsControl) { - const control = agentSessionsControl ?? accessor.get(IViewsService).getActiveViewWithId(ChatViewId)?.agentSessionsControl; + const control = agentSessionsControl ?? accessor.get(IViewsService).getActiveViewWithId(ChatViewId)?.agentSessionsControl; if (control) { control.refresh(); } else { @@ -1167,7 +1167,7 @@ export class FindAgentSessionInViewerAction extends Action2 { } override run(accessor: ServicesAccessor, agentSessionsControl?: IAgentSessionsControl) { - const control = agentSessionsControl ?? accessor.get(IViewsService).getActiveViewWithId(ChatViewId)?.agentSessionsControl; + const control = agentSessionsControl ?? accessor.get(IViewsService).getActiveViewWithId(ChatViewId)?.agentSessionsControl; if (control) { return control.openFind(); } else { @@ -1201,9 +1201,9 @@ abstract class UpdateChatViewWidthAction extends Action2 { await configurationService.updateValue(ChatConfiguration.ChatViewSessionsEnabled, true); } - let chatView = viewsService.getActiveViewWithId(ChatViewId); + let chatView = viewsService.getActiveViewWithId(ChatViewId); if (!chatView) { - chatView = await viewsService.openView(ChatViewId, false); + chatView = await viewsService.openView(ChatViewId, false); } if (!chatView) { return; // we need the chat view @@ -1343,7 +1343,7 @@ export class ToggleAgentSessionsSidebar extends Action2 { const commandService = accessor.get(ICommandService); const viewsService = accessor.get(IViewsService); - const chatView = viewsService.getActiveViewWithId(ChatViewId); + const chatView = viewsService.getActiveViewWithId(ChatViewId); const currentOrientation = chatView?.getSessionsViewerOrientation(); if (currentOrientation === AgentSessionsViewerOrientation.SideBySide) { @@ -1377,7 +1377,7 @@ export class FocusAgentSessionsAction extends Action2 { const configurationService = accessor.get(IConfigurationService); const commandService = accessor.get(ICommandService); - const chatView = await viewsService.openView(ChatViewId, true); + const chatView = await viewsService.openView(ChatViewId, true); const focused = chatView?.focusSessions(); if (focused) { return; diff --git a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts index fc6589af2b9484..09ba4d76b93ee6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts +++ b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts @@ -70,6 +70,11 @@ export interface IChatViewHost { toggle(): Promise; /** Starts showing the session of `sessionResource`, which its caller may send a first request to. */ startSession(sessionResource: URI, options: IChatViewHostSessionStartOptions): Promise; + /** + * Starts a new chat in the host, as New Chat of the Chat view does, and returns whether it takes it. A host that does not + * implement it leaves New Chat to the chat widget it acts on. + */ + newChat?(): boolean; } /** @@ -81,6 +86,8 @@ export interface IChatViewHostService extends IChatViewHost { revealIfShowing(sessionResource: URI, options?: IChatViewHostOpenOptions): Promise; + newChat(): boolean; + /** * Replaces the Chat view with `host` until the returned disposable is disposed. The host registered last is the one in * use; disposing it gives the use back to the one before. diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts index d433fe430427aa..7968b5dfa863ba 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts @@ -171,4 +171,8 @@ export class ChatViewHostService implements IChatViewHostService { startSession(sessionResource: URI, options: IChatViewHostSessionStartOptions): Promise { return this.host.startSession(sessionResource, options); } + + newChat(): boolean { + return this.host.newChat?.() ?? false; + } } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index ff50f904ca9847..4efcdfbbfb5ccf 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -68,7 +68,7 @@ import { ChatWidget, layoutChatWidgetForInputHeight } from '../../widget/chatWid import { ChatViewWelcomeController, IViewWelcomeDelegate } from '../../viewsWelcome/chatViewWelcomeController.js'; import { IChatViewsWelcomeDescriptor } from '../../viewsWelcome/chatViewsWelcome.js'; import { IWorkbenchLayoutService, LayoutSettings, Position } from '../../../../../services/layout/browser/layoutService.js'; -import { AgentSessionsViewerOrientation, AgentSessionsViewerPosition } from '../../agentSessions/agentSessions.js'; +import { AgentSessionsViewerOrientation, AgentSessionsViewerPosition, IChatViewSessionsControl } from '../../agentSessions/agentSessions.js'; import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; import { CHAT_WIDGET_VIEW_STATE_CACHE_LIMIT, ChatViewId, IChatWidgetService, IChatWidgetViewState, setModelPreservingInputTypedWhileLoading } from '../../chat.js'; import { IActivityService, ProgressBadge } from '../../../../../services/activity/common/activity.js'; @@ -145,7 +145,7 @@ type ChatViewSessionsLayoutClassification = { comment: 'Reports the layout of the sessions list in the chat view, to measure how the sessions list in the chat view is used.'; }; -export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { +export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate, IChatViewSessionsControl { private readonly memento: Memento; private readonly viewState: IChatViewPaneState; diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index f4aa83099a61f7..67469cf7c27944 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -183,6 +183,8 @@ export namespace ChatContextKeys { export const inSessionsChat = new RawContextKey('chatInSessionsChat', false, { type: 'boolean', description: localize('chatInSessionsChat', "True when the chat widget shows a session chat, as the Agents Window does.") }); /** Whether an editor window shows the Sessions view, whose chat part replaces the Chat view: it is set as the window starts. */ export const agentsViewActive = new RawContextKey('chatAgentsViewActive', false, { type: 'boolean', description: localize('chatAgentsViewActive', "True when the window shows the Agents view, whose chat part replaces the Chat view.") }); + export const chatViewSessionsListActive = new RawContextKey('chatViewSessionsListActive', false, { type: 'boolean', description: localize('chatViewSessionsListActive', "True when the Chat view shows the sessions of the Agents window, whose chats open in the Chat view or in the chat part.") }); + export const chatViewSessionsInChatPart = new RawContextKey('chatViewSessionsInChatPart', false, { type: 'boolean', description: localize('chatViewSessionsInChatPart', "True when the Chat view shows only the list of the sessions of the Agents window, whose chats open in the chat part.") }); export const hasCreatedSessionInAgentsWindow = new RawContextKey('chatHasCreatedSessionInAgentsWindow', false, { type: 'boolean', description: localize('chatHasCreatedSessionInAgentsWindow', "True when the user has created at least one session in the Agents Window.") }); export const agentSessionType = new RawContextKey('chatSessionType', '', { type: 'string', description: localize('agentSessionType', "The type of the current agent session item.") }); export const isAgentSessionChild = new RawContextKey('agentSessionIsChild', false, { type: 'boolean', description: localize('agentSessionIsChild', "True when the current agent session item is a child chat.") }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts index 25db610ef256bf..f5719606262b3d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts @@ -121,6 +121,20 @@ suite('ChatViewHostService', () => { assert.deepStrictEqual(steps, ['A', 'B', 'C', 'A', 'chat view']); }); + test('New Chat is left to the chat widget, unless the host that replaces the Chat view starts it', () => { + const { service } = createService(); + let started = 0; + const withoutNewChat = service.registerHost(upcastPartial({})); + const results = [service.newChat()]; + const withNewChat = service.registerHost(upcastPartial({ newChat: () => { started++; return true; } })); + results.push(service.newChat()); + withNewChat.dispose(); + withoutNewChat.dispose(); + results.push(service.newChat()); + + assert.deepStrictEqual({ results, started }, { results: [false, true, false], started: 1 }); + }); + test('reveal waits for the Chat view\'s widget, which its body creates as it first renders, only when asked to', async () => { const { service, view, created, accesses } = createService({ widgetCreatedLater: true }); const unchanged = await service.reveal(false); From c866db860ea513cf2da8acd716d79f38abd0cac4 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 04:15:25 +0100 Subject: [PATCH 006/165] chat: let a provider give the Chat view its sessions list Rework of this PR: instead of a view in place of the Chat view, the Chat view itself takes its sessions list from a provider registered with IChatViewSessionsListService, if any. The provider's list can show its own header, in place of the Chat view's title and actions of its sessions, and can ask for only the sessions, without the chat. Without a provider, the Chat view is as it is. IChatViewSessionsControl, the retyped sessions actions and the context keys for a view in place of the Chat view are no longer needed and removed; New Chat of a host that replaces the Chat view and the generic auxiliaryBarMaximizeHidden stay. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentSessions.contribution.ts | 6 -- .../browser/agentSessions/agentSessions.ts | 12 --- .../agentSessions/agentSessionsActions.ts | 16 ++-- .../widgetHosts/viewPane/chatViewPane.ts | 38 ++++++--- .../viewPane/chatViewSessionsList.ts | 81 +++++++++++++++++++ .../viewPane/media/chatViewPane.css | 5 ++ .../chat/common/actions/chatContextKeys.ts | 2 - .../widgetHosts/viewPane/chatViewPane.test.ts | 42 +++++++++- 8 files changed, 163 insertions(+), 39 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewSessionsList.ts diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts index 190dfac72a4403..9fd8154696b322 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts @@ -102,8 +102,6 @@ MenuRegistry.appendMenuItem(MenuId.AgentSessionsToolbar, { group: 'navigation', order: 3, icon: Codicon.filter, - // Not for the sessions of the Agents window, which this filter does not apply to - when: ChatContextKeys.chatViewSessionsListActive.negate(), } satisfies ISubmenuItem); MenuRegistry.appendMenuItem(MenuId.AgentSessionsToolbar, { @@ -116,7 +114,6 @@ MenuRegistry.appendMenuItem(MenuId.AgentSessionsToolbar, { order: 5, when: ContextKeyExpr.and( ChatContextKeys.agentSessionsViewerOrientation.isEqualTo(AgentSessionsViewerOrientation.Stacked), - ChatContextKeys.chatViewSessionsInChatPart.negate(), ChatContextKeys.agentSessionsViewerPosition.isEqualTo(AgentSessionsViewerPosition.Right) ) }); @@ -131,7 +128,6 @@ MenuRegistry.appendMenuItem(MenuId.AgentSessionsToolbar, { order: 5, when: ContextKeyExpr.and( ChatContextKeys.agentSessionsViewerOrientation.isEqualTo(AgentSessionsViewerOrientation.Stacked), - ChatContextKeys.chatViewSessionsInChatPart.negate(), ChatContextKeys.agentSessionsViewerPosition.isEqualTo(AgentSessionsViewerPosition.Left) ) }); @@ -146,7 +142,6 @@ MenuRegistry.appendMenuItem(MenuId.AgentSessionsToolbar, { order: 5, when: ContextKeyExpr.and( ChatContextKeys.agentSessionsViewerOrientation.isEqualTo(AgentSessionsViewerOrientation.SideBySide), - ChatContextKeys.chatViewSessionsInChatPart.negate(), ChatContextKeys.agentSessionsViewerPosition.isEqualTo(AgentSessionsViewerPosition.Right) ) }); @@ -161,7 +156,6 @@ MenuRegistry.appendMenuItem(MenuId.AgentSessionsToolbar, { order: 5, when: ContextKeyExpr.and( ChatContextKeys.agentSessionsViewerOrientation.isEqualTo(AgentSessionsViewerOrientation.SideBySide), - ChatContextKeys.chatViewSessionsInChatPart.negate(), ChatContextKeys.agentSessionsViewerPosition.isEqualTo(AgentSessionsViewerPosition.Left) ) }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index 7d396f3288fe01..1eb7517cf6b2db 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -226,18 +226,6 @@ export interface IAgentSessionsControl { collapseAllSections(): void; } -/** - * The sessions of the Chat view, which the actions of its sessions toolbar act on: those of the Chat view, or of a view that - * shows in its place. - */ -export interface IChatViewSessionsControl { - readonly agentSessionsControl: IAgentSessionsControl | undefined; - getSessionsViewerOrientation(): AgentSessionsViewerOrientation; - updateConfiguredSessionsViewerOrientation(orientation: 'stacked' | 'sideBySide' | unknown): void; - getLastDimensions(orientation: AgentSessionsViewerOrientation): { height: number; width: number } | undefined; - focusSessions(): boolean; -} - export const agentSessionReadIndicatorForeground = registerColor( 'agentSessionReadIndicator.foreground', { dark: transparent(foreground, 0.2), light: transparent(foreground, 0.2), hcDark: null, hcLight: null }, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts index be2c7f4075a2a0..18d32dd5395bd0 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts @@ -9,7 +9,7 @@ import { AgentSessionSection, IAgentSession, IAgentSessionSection, IMarshalledAg import { Action2, MenuId, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; -import { AGENT_SESSION_DELETE_ACTION_ID, AGENT_SESSION_RENAME_ACTION_ID, AgentSessionChatContextMenu, AgentSessionChatToolbar, AgentSessionProviders, AgentSessionsViewerOrientation, IAgentSessionsControl, IChatViewSessionsControl } from './agentSessions.js'; +import { AGENT_SESSION_DELETE_ACTION_ID, AGENT_SESSION_RENAME_ACTION_ID, AgentSessionChatContextMenu, AgentSessionChatToolbar, AgentSessionProviders, AgentSessionsViewerOrientation, IAgentSessionsControl } from './agentSessions.js'; import { IChatService } from '../../common/chatService/chatService.js'; import { IChatSessionsService } from '../../common/chatSessionsService.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; @@ -18,7 +18,7 @@ import { getChatSessionType, LocalChatSessionUri } from '../../common/model/chat import { IChatEditorOptions } from '../widgetHosts/editor/chatEditor.js'; import { ChatViewId, IChatWidgetService } from '../chat.js'; import { ACTIVE_GROUP, AUX_WINDOW_GROUP, PreferredGroup, SIDE_GROUP } from '../../../../services/editor/common/editorService.js'; -import { IView, IViewDescriptorService, ViewContainerLocation } from '../../../../common/views.js'; +import { IViewDescriptorService, ViewContainerLocation } from '../../../../common/views.js'; import { IWorkbenchLayoutService, Position } from '../../../../services/layout/browser/layoutService.js'; import { IAgentSessionsService } from './agentSessionsService.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -1142,7 +1142,7 @@ export class RefreshAgentSessionsViewerAction extends Action2 { } override run(accessor: ServicesAccessor, agentSessionsControl?: IAgentSessionsControl) { - const control = agentSessionsControl ?? accessor.get(IViewsService).getActiveViewWithId(ChatViewId)?.agentSessionsControl; + const control = agentSessionsControl ?? accessor.get(IViewsService).getActiveViewWithId(ChatViewId)?.agentSessionsControl; if (control) { control.refresh(); } else { @@ -1167,7 +1167,7 @@ export class FindAgentSessionInViewerAction extends Action2 { } override run(accessor: ServicesAccessor, agentSessionsControl?: IAgentSessionsControl) { - const control = agentSessionsControl ?? accessor.get(IViewsService).getActiveViewWithId(ChatViewId)?.agentSessionsControl; + const control = agentSessionsControl ?? accessor.get(IViewsService).getActiveViewWithId(ChatViewId)?.agentSessionsControl; if (control) { return control.openFind(); } else { @@ -1201,9 +1201,9 @@ abstract class UpdateChatViewWidthAction extends Action2 { await configurationService.updateValue(ChatConfiguration.ChatViewSessionsEnabled, true); } - let chatView = viewsService.getActiveViewWithId(ChatViewId); + let chatView = viewsService.getActiveViewWithId(ChatViewId); if (!chatView) { - chatView = await viewsService.openView(ChatViewId, false); + chatView = await viewsService.openView(ChatViewId, false); } if (!chatView) { return; // we need the chat view @@ -1343,7 +1343,7 @@ export class ToggleAgentSessionsSidebar extends Action2 { const commandService = accessor.get(ICommandService); const viewsService = accessor.get(IViewsService); - const chatView = viewsService.getActiveViewWithId(ChatViewId); + const chatView = viewsService.getActiveViewWithId(ChatViewId); const currentOrientation = chatView?.getSessionsViewerOrientation(); if (currentOrientation === AgentSessionsViewerOrientation.SideBySide) { @@ -1377,7 +1377,7 @@ export class FocusAgentSessionsAction extends Action2 { const configurationService = accessor.get(IConfigurationService); const commandService = accessor.get(ICommandService); - const chatView = await viewsService.openView(ChatViewId, true); + const chatView = await viewsService.openView(ChatViewId, true); const focused = chatView?.focusSessions(); if (focused) { return; diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 4efcdfbbfb5ccf..1d432482594c26 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -68,7 +68,8 @@ import { ChatWidget, layoutChatWidgetForInputHeight } from '../../widget/chatWid import { ChatViewWelcomeController, IViewWelcomeDelegate } from '../../viewsWelcome/chatViewWelcomeController.js'; import { IChatViewsWelcomeDescriptor } from '../../viewsWelcome/chatViewsWelcome.js'; import { IWorkbenchLayoutService, LayoutSettings, Position } from '../../../../../services/layout/browser/layoutService.js'; -import { AgentSessionsViewerOrientation, AgentSessionsViewerPosition, IChatViewSessionsControl } from '../../agentSessions/agentSessions.js'; +import { AgentSessionsViewerOrientation, AgentSessionsViewerPosition } from '../../agentSessions/agentSessions.js'; +import { IChatViewSessionsList, IChatViewSessionsListControl, IChatViewSessionsListService } from './chatViewSessionsList.js'; import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; import { CHAT_WIDGET_VIEW_STATE_CACHE_LIMIT, ChatViewId, IChatWidgetService, IChatWidgetViewState, setModelPreservingInputTypedWhileLoading } from '../../chat.js'; import { IActivityService, ProgressBadge } from '../../../../../services/activity/common/activity.js'; @@ -145,7 +146,7 @@ type ChatViewSessionsLayoutClassification = { comment: 'Reports the layout of the sessions list in the chat view, to measure how the sessions list in the chat view is used.'; }; -export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate, IChatViewSessionsControl { +export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private readonly memento: Memento; private readonly viewState: IChatViewPaneState; @@ -204,6 +205,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate, ICha @ICommandService private readonly commandService: ICommandService, @IActivityService private readonly activityService: IActivityService, @IHostService private readonly hostService: IHostService, + @IChatViewSessionsListService private readonly sessionsListService: IChatViewSessionsListService, @IMicCaptureService private readonly micCaptureService: IMicCaptureService, @ITtsPlaybackService private readonly ttsPlaybackService: ITtsPlaybackService, @IVoiceSessionController private readonly voiceSessionController: IVoiceSessionController, @@ -863,9 +865,11 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate, ICha private sessionsTitle: HTMLElement | undefined; private sessionsNewButtonContainer: HTMLElement | undefined; private sessionsControlContainer: HTMLElement | undefined; - private sessionsControl: AgentSessionsControl | undefined; + private sessionsControl: IChatViewSessionsListControl | undefined; + /** The sessions list a provider gives in place of the Chat view's own, if any. */ + private providedSessionsList: IChatViewSessionsList | undefined; - get agentSessionsControl(): AgentSessionsControl | undefined { return this.sessionsControl; } + get agentSessionsControl(): AgentSessionsControl | undefined { return this.sessionsControl instanceof AgentSessionsControl ? this.sessionsControl : undefined; } private sessionsListRevealCount = 0; /** Shows the Sessions list for a navigation flow without replacing the current chat or saved layout. */ @@ -894,7 +898,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate, ICha /** Number of in-flight {@link loadSession} calls, during which the reported sessions layout is not settled. */ private _loadingSessionCount = 0; - private createSessionsControl(parent: HTMLElement): AgentSessionsControl { + private createSessionsControl(parent: HTMLElement): IChatViewSessionsListControl { const sessionsContainer = this.sessionsContainer = parent.appendChild($('.agent-sessions-container')); // Sessions Title @@ -931,7 +935,18 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate, ICha // Sessions Control this.sessionsControlContainer = append(sessionsContainer, $('.agent-sessions-control-container')); - const sessionsControl = this.sessionsControl = this._register(this.instantiationService.createInstance(AgentSessionsControl, this.sessionsControlContainer, { + const provided = this.providedSessionsList = this.sessionsListService.provider?.create(this.sessionsControlContainer, this.instantiationService, { overrideStyles: this.getLocationBasedColors().listOverrideStyles }); + if (provided) { + // A list of its own header and actions, in place of those of the Chat view's list + setVisibility(!provided.hasHeader, sessionsTitleContainer); + setVisibility(false, newSessionButtonContainer); + this._register(autorun(reader => { + const sessionsOnly = provided.sessionsOnly.read(reader); + this.viewPaneContainer?.classList.toggle('sessions-only', sessionsOnly); + this.relayout(); + })); + } + const sessionsControl: IChatViewSessionsListControl = this.sessionsControl = this._register(provided?.control ?? this.instantiationService.createInstance(AgentSessionsControl, this.sessionsControlContainer, { initiallyVisible: false, source: 'chatViewPane', filter: sessionsFilter, @@ -1009,7 +1024,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate, ICha } let newSessionsContainerVisible: boolean; - if (this.sessionsListRevealCount > 0) { + if (this.providedSessionsList?.sessionsOnly.get()) { + newSessionsContainerVisible = !this.welcomeController?.isShowingWelcome.get(); // only the sessions show + } else if (this.sessionsListRevealCount > 0) { newSessionsContainerVisible = true; } else if (!this.configurationService.getValue(ChatConfiguration.ChatViewSessionsEnabled)) { newSessionsContainerVisible = false; // disabled in settings @@ -1218,7 +1235,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate, ICha //#endregion - private registerControlsListeners(sessionsControl: AgentSessionsControl, chatWidget: ChatWidget, welcomeController: ChatViewWelcomeController): void { + private registerControlsListeners(sessionsControl: IChatViewSessionsListControl, chatWidget: ChatWidget, welcomeController: ChatViewWelcomeController): void { // Sessions control visibility is impacted by multiple things: // - chat widget being in empty state or showing a chat @@ -1848,7 +1865,8 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate, ICha const oldSessionsViewerOrientation = this.sessionsViewerOrientation; let newSessionsViewerOrientation: AgentSessionsViewerOrientation; - switch (this.sessionsViewerOrientationConfiguration) { + const sessionsOnly = !!this.providedSessionsList?.sessionsOnly.get(); + switch (sessionsOnly ? 'stacked' : this.sessionsViewerOrientationConfiguration) { // Stacked case 'stacked': newSessionsViewerOrientation = AgentSessionsViewerOrientation.Stacked; @@ -1909,7 +1927,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate, ICha let availableSessionsHeight = height - sessionsTitleHeight; let reservedChatWidgetHeight = 0; if (this.sessionsViewerOrientation === AgentSessionsViewerOrientation.Stacked) { - reservedChatWidgetHeight = Math.max(ChatViewPane.MIN_CHAT_WIDGET_HEIGHT, this._widget?.input?.height.get() ?? 0); + reservedChatWidgetHeight = sessionsOnly ? 0 : Math.max(ChatViewPane.MIN_CHAT_WIDGET_HEIGHT, this._widget?.input?.height.get() ?? 0); availableSessionsHeight -= reservedChatWidgetHeight; } else { availableSessionsHeight -= this.sessionsNewButtonContainer?.offsetHeight ?? 0; diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewSessionsList.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewSessionsList.ts new file mode 100644 index 00000000000000..1a1d49ee68d101 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewSessionsList.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { IObservable } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { InstantiationType, registerSingleton } from '../../../../../../platform/instantiation/common/extensions.js'; +import { createDecorator, IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IListStyles } from '../../../../../../base/browser/ui/list/listWidget.js'; +import { IStyleOverride } from '../../../../../../platform/theme/browser/defaultStyles.js'; +import { AgentSessionRefreshReason, IAgentSession } from '../../agentSessions/agentSessionsModel.js'; + +/** The sessions list that the Chat view shows in its sessions section: its own, or one that a provider gives it. */ +export interface IChatViewSessionsListControl extends IDisposable { + setVisible(visible: boolean): void; + layout(height: number, width: number): void; + focus(): void; + scrollToTop(): void; + refresh(reason?: AgentSessionRefreshReason): void; + /** Updates what it lists, such as as the Chat view's orientation changes its grouping; resolves to whether it did. */ + update(): Promise; + reveal(sessionResource: URI): boolean; + clearFocus(): void; + hasFocusOrSelection(): boolean; + /** The sessions of the Chat view's own list that have focus, which actions of that list act on. */ + getFocus(): IAgentSession[]; +} + +/** A sessions list of the Chat view, with how the Chat view shows it. */ +export interface IChatViewSessionsList { + readonly control: IChatViewSessionsListControl; + /** Whether it shows its own header, with its title and its actions: the Chat view then shows none above it. */ + readonly hasHeader: boolean; + /** Whether the Chat view shows only the sessions, without its chat. */ + readonly sessionsOnly: IObservable; +} + +/** Gives the Chat view a sessions list in place of its own. */ +export interface IChatViewSessionsListProvider { + /** Creates the sessions list in `container`, with `instantiationService` of the Chat view. */ + create(container: HTMLElement, instantiationService: IInstantiationService, options: { readonly overrideStyles: IStyleOverride }): IChatViewSessionsList; +} + +export const IChatViewSessionsListService = createDecorator('chatViewSessionsListService'); + +/** + * Where the Chat view takes its sessions list from: its own, unless a provider is registered as the Chat view is created. The + * provider registered last is the one in use. + */ +export interface IChatViewSessionsListService { + readonly _serviceBrand: undefined; + readonly provider: IChatViewSessionsListProvider | undefined; + registerProvider(provider: IChatViewSessionsListProvider): IDisposable; +} + +export class ChatViewSessionsListService implements IChatViewSessionsListService { + + declare readonly _serviceBrand: undefined; + + /** One registration each, also of the same provider. */ + private readonly _providers: { readonly provider: IChatViewSessionsListProvider }[] = []; + + get provider(): IChatViewSessionsListProvider | undefined { + return this._providers.at(-1)?.provider; + } + + registerProvider(provider: IChatViewSessionsListProvider): IDisposable { + const registration = { provider }; + this._providers.push(registration); + return toDisposable(() => { + const index = this._providers.indexOf(registration); + if (index >= 0) { + this._providers.splice(index, 1); + } + }); + } +} + +registerSingleton(IChatViewSessionsListService, ChatViewSessionsListService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css index b0f2fa5e108da0..203758b0894a11 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css @@ -235,3 +235,8 @@ } } } + +/* Only the sessions show, of a list that a provider gives the Chat view, without its chat */ +.chat-viewpane.sessions-only .chat-controls-container { + display: none; +} diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 67469cf7c27944..f4aa83099a61f7 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -183,8 +183,6 @@ export namespace ChatContextKeys { export const inSessionsChat = new RawContextKey('chatInSessionsChat', false, { type: 'boolean', description: localize('chatInSessionsChat', "True when the chat widget shows a session chat, as the Agents Window does.") }); /** Whether an editor window shows the Sessions view, whose chat part replaces the Chat view: it is set as the window starts. */ export const agentsViewActive = new RawContextKey('chatAgentsViewActive', false, { type: 'boolean', description: localize('chatAgentsViewActive', "True when the window shows the Agents view, whose chat part replaces the Chat view.") }); - export const chatViewSessionsListActive = new RawContextKey('chatViewSessionsListActive', false, { type: 'boolean', description: localize('chatViewSessionsListActive', "True when the Chat view shows the sessions of the Agents window, whose chats open in the Chat view or in the chat part.") }); - export const chatViewSessionsInChatPart = new RawContextKey('chatViewSessionsInChatPart', false, { type: 'boolean', description: localize('chatViewSessionsInChatPart', "True when the Chat view shows only the list of the sessions of the Agents window, whose chats open in the chat part.") }); export const hasCreatedSessionInAgentsWindow = new RawContextKey('chatHasCreatedSessionInAgentsWindow', false, { type: 'boolean', description: localize('chatHasCreatedSessionInAgentsWindow', "True when the user has created at least one session in the Agents Window.") }); export const agentSessionType = new RawContextKey('chatSessionType', '', { type: 'string', description: localize('agentSessionType', "The type of the current agent session item.") }); export const isAgentSessionChild = new RawContextKey('agentSessionIsChild', false, { type: 'boolean', description: localize('agentSessionIsChild', "True when the current agent session item is a child chat.") }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index 17c349b7be14c0..34e111a05e20b9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -8,7 +8,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../../../.. import { CancellationError } from '../../../../../../../base/common/errors.js'; import { DeferredPromise, RunOnceScheduler, timeout } from '../../../../../../../base/common/async.js'; import { Emitter, Event } from '../../../../../../../base/common/event.js'; -import { constObservable } from '../../../../../../../base/common/observable.js'; +import { constObservable, observableValue } from '../../../../../../../base/common/observable.js'; import { DisposableStore, MutableDisposable, toDisposable } from '../../../../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { upcastPartial } from '../../../../../../../base/test/common/mock.js'; @@ -20,6 +20,7 @@ import { NullLogService } from '../../../../../../../platform/log/common/log.js' import { AccountPolicyGateState } from '../../../../../../services/policies/common/accountPolicyService.js'; import { TestContextService, TestStorageService } from '../../../../../../test/common/workbenchTestServices.js'; import { ChatViewPane } from '../../../../browser/widgetHosts/viewPane/chatViewPane.js'; +import { ChatViewSessionsListService, IChatViewSessionsListProvider } from '../../../../browser/widgetHosts/viewPane/chatViewSessionsList.js'; import { ChatWidget } from '../../../../browser/widget/chatWidget.js'; import { mainWindow } from '../../../../../../../base/browser/window.js'; import { ChatOpenDiagnosticsService } from '../../../../common/chatOpenDiagnosticsService.js'; @@ -73,6 +74,44 @@ suite('ChatViewPane sessions refresh', () => { assert.deepStrictEqual(visibility, [false, true, false, true, false, false]); }); + test('a sessions list that a provider gives shows, while it asks for only the sessions, also with a chat and the sessions off in settings', async () => { + const visibility: boolean[] = []; + const sessionsOnly = observableValue('sessionsOnly', true); + const host = { + sessionsContainer: document.createElement('div'), + viewPaneContainer: document.createElement('div'), + sessionsControl: { setVisible: (visible: boolean) => visibility.push(visible) }, + providedSessionsList: { sessionsOnly }, + configurationService: new TestConfigurationService({ [ChatConfiguration.ChatViewSessionsEnabled]: false }), + sessionsViewerOrientation: AgentSessionsViewerOrientation.Stacked, + lastDimensions: { width: 1000, height: 600 }, + sessionsViewerVisibilityContext: { set: () => { } }, + isBodyVisible: () => true, + chatEntitlementService: { sentiment: { completed: true } }, + _widget: { isEmpty: () => false }, + _sessionsListSuppressionCount: 0, + }; + const update = Reflect.get(ChatViewPane.prototype, 'updateSessionsControlVisibility') as (this: typeof host) => void; + update.call(host); + sessionsOnly.set(false, undefined); + update.call(host); + assert.deepStrictEqual(visibility, [true, false]); + }); + + test('the Chat view takes the sessions list of the provider registered last, and its own once none is', () => { + const service = new ChatViewSessionsListService(); + const provider = (name: string) => upcastPartial({ create: () => { throw new Error(name); } }); + const a = provider('a'); + const b = provider('b'); + const first = service.registerProvider(a); + const second = service.registerProvider(b); + const providers = [service.provider === b]; + second.dispose(); + providers.push(service.provider === a); + first.dispose(); + assert.deepStrictEqual({ providers, none: service.provider }, { providers: [true, true], none: undefined }); + }); + test('sessions layout telemetry reports the settled layout once shown and restored, then only changes', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const events: unknown[] = []; const host = { @@ -140,6 +179,7 @@ suite('ChatViewPane sessions refresh', () => { }, }, hostService: { onDidChangeFocus: focusChanged.event }, + sessionsListService: { provider: undefined }, configurationService: new TestConfigurationService(), onDidChangeBodyVisibility: Event.None, getLocationBasedColors: () => ({ listOverrideStyles: {} }), From 03b64d8b9c8a30e8c894956dde320f9ac31ca9a0 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 04:19:22 +0100 Subject: [PATCH 007/165] Sessions: show a session's changes as the input's working set outside the Agents window A session's chat outside the Agents window, such as in an editor window, can now show its Agent Host session's changes as a working set above its input, with Keep and Undo, in place of its Changes pill, as the editor window's chat does. Hosts opt in with the `workingSet` input option, or with SESSION_CHAT_VIEW_WORKING_SET_HOST_OPTIONS for a chat editor's host. The Agents window never shows it. - AgentHostSessionWorkingSet binds only the working set of AgentHostSessionInputPills, whose Keep and Undo run the host's changeset operations, and reports when the Changes pill hides. - The chat widget and input take a `sessionWorkingSet` option that allows the working set also for a session's chat. - SessionChatInputToolbar hides its Changes pill while the working set shows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/sessionChatInputToolbar.test.ts | 41 +++++++++++++++++++ .../agentHost/agentHostSessionInputPills.ts | 23 ++++++++++- src/vs/workbench/contrib/chat/browser/chat.ts | 6 +++ .../contrib/chat/browser/widget/chatWidget.ts | 1 + .../browser/widget/input/chatInputPart.ts | 7 +++- .../agentHostSessionInputPills.test.ts | 20 ++++++++- .../widget/input/chatInputPart.test.ts | 23 ++++++++++- .../contrib/sessions/browser/chat/chatView.ts | 6 +++ .../browser/chat/sessionChatInputToolbar.ts | 14 ++++++- .../sessions/browser/chatEditor/chatEditor.ts | 13 +++++- .../sessions/browser/parts/chatView.ts | 5 +++ 11 files changed, 150 insertions(+), 9 deletions(-) diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts index 180aaa7d05abbd..6355502ab501e3 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts @@ -1104,6 +1104,47 @@ suite('SessionChatInputToolbar', () => { }); }); + test('hides the Changes pill while its input shows the session\'s changes as its working set', () => { + const { instantiationService } = createServices(); + const root = URI.file('/repo'); + const workspace = constObservable(upcastPartial({ + folders: [{ root, name: 'repo', description: undefined, workingDirectory: root, gitRepository: undefined }], + })); + const chat = upcastPartial({ + resource: URI.parse('chat:main'), + workspace, + changesets: constObservable([]), + changes: constObservable([{ modifiedUri: URI.file('/chat-change.ts'), insertions: 10, deletions: 4 }]), + }); + const session = upcastPartial({ + loading: constObservable(false), + sessionId: 'provider:session', + capabilities: constObservable({ supportsMultipleChats: true }), + resource: URI.parse('session:1'), + chats: constObservable([chat]), + mainChat: constObservable(chat), + workspace, + }); + instantiationService.stub(ISessionHost, upcastPartial({ kind: SessionHostKind.MainWindow, parent: undefined })); + instantiationService.stub(ISessionsService, 'getPaneOfHost', () => undefined); + instantiationService.stub(ISessionsService, 'panes', constObservable([])); + instantiationService.stub(IAgentWorkbenchLayoutService, upcastPartial({ + getContainer: window => window.document.body, + revealEditorPartExplicitly: () => { }, + })); + stubSessionsHostLayout(instantiationService); + const toolbar = store.add(instantiationService.createInstance(SessionChatInputToolbar, false, undefined, undefined)); + toolbar.setSession(session, chat); + const hasChangesPill = () => !!toolbar.element.querySelector('.chat-changes-pill-button'); + const before = hasChangesPill(); + const workingSetShown = observableValue('workingSetShown', true); + toolbar.setChangesHidden(workingSetShown); + const whileShown = hasChangesPill(); + workingSetShown.set(false, undefined); + + assert.deepStrictEqual({ before, whileShown, after: hasChangesPill() }, { before: true, whileShown: false, after: true }); + }); + test('opens the Changes editor of its own chat from a pill in a chat window', () => { const { instantiationService } = createServices(); const createWorkspace = (folder: string) => constObservable(upcastPartial({ diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts index ec1ddf529eb329..c047c4fb6746b6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts @@ -573,6 +573,9 @@ export class AgentHostSessionInputPills extends Disposable { /** Runs the file operations on each working set channel one after another, so a Keep staged with Git waits for one in progress. */ private readonly _fileOperations = new SequencerByKey(); + /** Whether the session's changes show as the input's working set, or will once its host advertises them, in place of the Changes pill. */ + readonly changesPillHidden: IObservable; + constructor( private readonly _widget: ChatWidget, compact: ChatPillsCompactMode, @@ -714,7 +717,7 @@ export class AgentHostSessionInputPills extends Disposable { const catalogue = creationCatalogue.read(reader); return !!catalogue && expiredCreationCatalogue.read(reader) !== catalogue && !workingSetEnabled.read(reader); }); - const changesPillHidden = derived(this, reader => workingSetEnabled.read(reader) || workingSetPending.read(reader)); + const changesPillHidden = this.changesPillHidden = derived(this, reader => workingSetEnabled.read(reader) || workingSetPending.read(reader)); const changesetTarget = derivedOpts({ owner: this, equalsFn: changesetTargetEquals }, reader => { const currentResolution = resolution.read(reader); const chat = chatResource.read(reader); @@ -875,6 +878,9 @@ export class AgentHostSessionInputPills extends Disposable { reader.store.add(this._widget.inputPart.registerSessionFileChanges(workingSetChanges, undo, workingSetKeep)); } })); + if (this.workingSetOnly) { + return; + } const changeStats = derivedOpts({ owner: this, equalsFn: chatChangesStatsEqual }, reader => { const diffs = changes.read(reader); return diffs.length === 0 @@ -1174,6 +1180,11 @@ export class AgentHostSessionInputPills extends Disposable { return false; } + /** Whether it binds only the session's working set, for an input that shows the session's pills itself. */ + protected get workingSetOnly(): boolean { + return false; + } + private _buildReferenceSections( links: readonly string[], kind: 'pullRequest' | 'issue', @@ -1703,3 +1714,13 @@ export class AgentHostSessionInputPills extends Disposable { this._browserChanged.trigger(undefined); } } + +/** + * Shows an Agent Host session's changes as the working set of a chat input that shows the session's pills itself, such as + * a session's chat outside the Agents window: its Changes pill hides while {@link changesPillHidden}. + */ +export class AgentHostSessionWorkingSet extends AgentHostSessionInputPills { + protected override get workingSetOnly(): boolean { + return true; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 4fe575a02ab66b..f82a9fbe8dde68 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -401,6 +401,12 @@ export interface IChatWidgetViewOptions { */ isSessionsWindow?: boolean; + /** + * Whether the input shows its session's changes as a working set, as the editor window's chat does, also while + * {@link isSessionsWindow} is set, such as for a session's chat outside the Agents window. Never in the Agents window. + */ + sessionWorkingSet?: boolean; + /** Checked state of a permissions picker hosted outside the chat input. */ readonly sandboxToggleChecked?: IObservable; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index df31cc80632d84..872e527ae2985f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -2958,6 +2958,7 @@ export class ChatWidget extends Disposable implements IChatWidget { defaultMode: this.viewOptions.defaultMode, sessionTypePickerDelegate: this.viewOptions.sessionTypePickerDelegate, isSessionsWindow: this.viewOptions.isSessionsWindow, + sessionWorkingSet: this.viewOptions.sessionWorkingSet, inputPartHorizontalPadding: this.viewOptions.inputPartHorizontalPadding, hostModelSelection: this.viewOptions.hostModelSelection, deferredNotificationsEnabled: this.viewOptions.deferredNotificationsEnabled, diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index b9c13b6e3b41a0..418ad1d4e5613c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -381,6 +381,8 @@ export interface IChatInputPartOptions { * When true, the secondary toolbar (permissions picker) is hidden. */ isSessionsWindow?: boolean; + /** Whether it shows its session's working set also while {@link isSessionsWindow} is set; never in the Agents window. */ + sessionWorkingSet?: boolean; /** * Total horizontal gutter (in pixels) reserved outside the input box when * computing the editor width. Defaults account for the `.interactive-input-part` @@ -5543,10 +5545,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge /** * Whether this input can show the session working set. Only the editor - * window offers it; the Agents window doesn't, even in a chat editor. + * window offers it, also to a session's chat that asks for it; the Agents + * window doesn't, even in a chat editor. */ get supportsSessionWorkingSet(): boolean { - return this.options.renderWorkingSet && !this.options.isSessionsWindow && !this.environmentService.isSessionsWindow; + return (this.options.sessionWorkingSet || (this.options.renderWorkingSet && !this.options.isSessionsWindow)) && !this.environmentService.isSessionsWindow; } renderChatEditingSessionState(chatEditingSession: IChatEditingSession | null) { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts index be1451b7c47db4..674c0c4a7a452d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts @@ -324,7 +324,7 @@ suite('AgentHostSessionInputPills', () => { }); }; - function createActivityPills(initialSession: SessionState, initialChat?: ChatState, gitHubService?: IGitHubService, connectionAuthority = 'local', workbenchGitHubService?: IWorkbenchGitHubService, transcript?: { service: IChatArtifactsService; local?: boolean }, options?: { sessionResource?: URI; configurationService?: IConfigurationService; defaultChangesetKind?: DefaultChangesetKind; dialogService?: IDialogService; notificationService?: INotificationService; supportsSessionWorkingSet?: boolean; getUriLabel?: ILabelService['getUriLabel']; workingSetUndo?: boolean; openerService?: IOpenerService; resourceUris?: IAgentHostResourceUriMapper; telemetryService?: ITelemetryService; nativeHost?: boolean }) { + function createActivityPills(initialSession: SessionState, initialChat?: ChatState, gitHubService?: IGitHubService, connectionAuthority = 'local', workbenchGitHubService?: IWorkbenchGitHubService, transcript?: { service: IChatArtifactsService; local?: boolean }, options?: { sessionResource?: URI; configurationService?: IConfigurationService; defaultChangesetKind?: DefaultChangesetKind; dialogService?: IDialogService; notificationService?: INotificationService; supportsSessionWorkingSet?: boolean; getUriLabel?: ILabelService['getUriLabel']; workingSetUndo?: boolean; openerService?: IOpenerService; resourceUris?: IAgentHostResourceUriMapper; telemetryService?: ITelemetryService; nativeHost?: boolean; workingSetOnly?: boolean }) { const instantiationService = createInstantiationService(); if (options?.telemetryService) { instantiationService.stub(ITelemetryService, options.telemetryService); @@ -480,6 +480,9 @@ suite('AgentHostSessionInputPills', () => { protected override get workingSetUndoEnabled() { return !!options?.workingSetUndo; } + protected override get workingSetOnly() { + return !!options?.workingSetOnly; + } } const pills = store.add(instantiationService.createInstance(TestAgentHostSessionInputPills, widget, false)); return { @@ -3142,7 +3145,7 @@ suite('AgentHostSessionInputPills', () => { }); }); - function createSessionWorkingSetPills(options?: { connectionAuthority?: string; resourceUris?: IAgentHostResourceUriMapper }) { + function createSessionWorkingSetPills(options?: { connectionAuthority?: string; resourceUris?: IAgentHostResourceUriMapper; workingSetOnly?: boolean }) { const errors: string[] = []; const confirmations: Pick[] = []; let confirmed = false; @@ -3161,6 +3164,7 @@ suite('AgentHostSessionInputPills', () => { }), undefined, options?.connectionAuthority ?? 'remote-test', undefined, undefined, { getUriLabel: labelService.getUriLabel, resourceUris: options?.resourceUris, + workingSetOnly: options?.workingSetOnly, dialogService: upcastPartial({ confirm: async ({ title, message, primaryButton, type }) => { confirmations.push({ title, message, primaryButton, type }); @@ -3311,6 +3315,18 @@ suite('AgentHostSessionInputPills', () => { }); }); + test('binds only the working set for an input that shows the session\'s pills itself, with the same Keep and Undo', async () => { + const { harness, setOperations, readState } = createSessionWorkingSetPills({ workingSetOnly: true }); + setOperations([acceptChanges(ChangesetOperationStatus.Idle), discardWorkingSetChanges(ChangesetOperationStatus.Idle)]); + await harness.setWorkingSetEnabled(true); + + assert.deepStrictEqual({ workingSet: readState(), changesPillHidden: harness.pills.changesPillHidden.get(), pills: harness.persistentContent.childElementCount }, { + workingSet: { changeset: 'vendor-diff:/opaque/working-set', files: ['file.ts'], keep: true, undo: true }, + changesPillHidden: true, + pills: 0, + }); + }); + test('opens, keeps and undoes Working Set files through the ambient host of a remote window', async () => { // The ambient host of a remote window resolves as `local` but runs on the remote, so its files aren't on this machine const resourceUris = createEditorRemoteAgentHostResourceUriMapper('wsl+debian', agentHostAuthority('vscode-remote://wsl+debian')); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPart.test.ts index 5d3e7ad042dcec..90679c6da46c6f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputPart.test.ts @@ -729,7 +729,7 @@ suite('ChatInputPart', () => { }, { input: false, hostSubmit: true }); }); - function createWorkingSetInput(isSessionsWindow = false, sessionResource = URI.from({ scheme: SessionType.AgentHostCopilot, path: '/session' }), inAgentsWindow = isSessionsWindow) { + function createWorkingSetInput(isSessionsWindow = false, sessionResource = URI.from({ scheme: SessionType.AgentHostCopilot, path: '/session' }), inAgentsWindow = isSessionsWindow, sessionWorkingSet = false) { const configurationService = new TestConfigurationService(); store.add(configurationService.onDidChangeConfigurationEmitter); const instantiationService = workbenchInstantiationService(undefined, store); @@ -764,7 +764,8 @@ suite('ChatInputPart', () => { const keptChanges = new Lazy(() => store.add(instantiationService.createInstance(ChatSessionWorkingSetKeptChanges))); let inputFocusCalls = 0; const inputPart = Object.assign(Object.create(ChatInputPart.prototype), { - options: { renderWorkingSet: true, isSessionsWindow, menus: { telemetrySource: 'test' } }, + // A session's chat asks for the working set without rendering the editing session's + options: { renderWorkingSet: !sessionWorkingSet, isSessionsWindow, sessionWorkingSet, menus: { telemetrySource: 'test' } }, configurationService, environmentService: upcastPartial({ isSessionsWindow: inAgentsWindow }), instantiationService, @@ -933,6 +934,24 @@ suite('ChatInputPart', () => { }); } + test('shows the working set of a session\'s chat that asks for it outside the Agents window, and never in it', async () => { + const shown = async (inAgentsWindow: boolean) => { + const harness = createWorkingSetInput(true, undefined, inAgentsWindow, true); + store.add(harness.inputPart.registerSessionFileChanges(constObservable([emptySessionEntryDiff(URI.file('/before.ts'), URI.file('/after.ts'))]))); + await harness.setEnabled(true); + harness.inputPart.renderChatEditingSessionState(null); + return { + supported: harness.inputPart.supportsSessionWorkingSet, + workingSet: !!harness.container.querySelector('.chat-session-working-set') && harness.container.style.display !== 'none', + }; + }; + + assert.deepStrictEqual({ editorWindow: await shown(false), agentsWindow: await shown(true) }, { + editorWindow: { supported: true, workingSet: true }, + agentsWindow: { supported: false, workingSet: false }, + }); + }); + test('leaves metadata-backed providers on their existing working-set presentation', async () => { const { inputPart, container, sessionResource, setEnabled, setSessionChanges } = createWorkingSetInput(false, URI.parse('copilot-cloud-agent:/session')); const editingSession = new class extends mock() { diff --git a/src/vs/workbench/contrib/sessions/browser/chat/chatView.ts b/src/vs/workbench/contrib/sessions/browser/chat/chatView.ts index 7313ea60fcf862..c4114136933398 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/chatView.ts +++ b/src/vs/workbench/contrib/sessions/browser/chat/chatView.ts @@ -68,6 +68,7 @@ import { NewChatWidget } from './newChatWidget.js'; import { NewChatInSessionWidget } from './newChatInSessionWidget.js'; import { SessionInputBanners } from '../sessionInputBanners/sessionInputBanners.js'; import { SESSION_CHAT_INPUT_TOOLBAR_HEIGHT, SessionChatInputToolbar } from './sessionChatInputToolbar.js'; +import { AgentHostSessionWorkingSet } from '../../../chat/browser/agentSessions/agentHost/agentHostSessionInputPills.js'; import { ResponseSelectionSideChatController } from '../../../chat/browser/responseSelectionSideChatController.js'; import { logResponseSelectionWidgetAction } from '../../common/sessionsTelemetry.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; @@ -535,6 +536,7 @@ export class ChatView extends AbstractChatView { inputPlaceholder: options.input?.placeholder, inputEditorOptions: options.inputEditorOptions, isSessionsWindow: true, + sessionWorkingSet: options.input?.workingSet === true, validateInput: query => { if (this.scheduledChatsService.enabled.get() && query !== this._widget.getInput()) { throw new Error(localize('chat.inputChangedBeforeSend', "The input changed while preparing this request. Review it before sending again.")); @@ -702,6 +704,10 @@ export class ChatView extends AbstractChatView { // Floating status pills above the input. this._chatPills = this._register(instantiationService.createInstance(SessionChatInputToolbar, false, () => this._widget.focusInput(), options.session)); + if (options.input?.workingSet) { + const workingSet = this._register(scopedInstantiationService.createInstance(AgentHostSessionWorkingSet, this._widget, 'auto')); + this._chatPills.setChangesHidden(workingSet.changesPillHidden); + } this._register(this._widget.registerBackgroundActivityPillSections(this._chatPills.backgroundActivityPillSections)); this._register(this._widget.inputEditor.onKeyDown(event => { if (isFocusChatPillsKeyDown(event) && this._chatPills.focusFirst()) { diff --git a/src/vs/workbench/contrib/sessions/browser/chat/sessionChatInputToolbar.ts b/src/vs/workbench/contrib/sessions/browser/chat/sessionChatInputToolbar.ts index 157cf7279dbbd0..50aac7f4625e0c 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/sessionChatInputToolbar.ts +++ b/src/vs/workbench/contrib/sessions/browser/chat/sessionChatInputToolbar.ts @@ -419,6 +419,8 @@ export class SessionChatInputToolbar extends Disposable { readonly onDidChangeVisibility: Event; readonly activityBarActions: IObservable; private readonly _inputPills: ChatInputPills; + /** Whether the Changes pill hides, such as while the input shows the session's changes as its working set. */ + private readonly _changesHidden = observableValue>(this, constObservable(false)); private _inputElement: HTMLElement | undefined; /** Sentinel distinguishing "no override" from an explicit `undefined` session. */ @@ -811,7 +813,12 @@ export class SessionChatInputToolbar extends Disposable { debugName: 'SessionChatInputToolbar.content', compact, enabled: pillsVisible, - sources: derived(reader => sources.sources.flatMap(source => source.kind === SessionChatPillKind.Artifacts ? [...integratedSources.read(reader), source] : [source])), + sources: derived(reader => { + const changesHidden = this._changesHidden.read(reader).read(reader); + return sources.sources + .filter(source => !changesHidden || source.kind !== SessionChatPillKind.Changes) + .flatMap(source => source.kind === SessionChatPillKind.Artifacts ? [...integratedSources.read(reader), source] : [source]); + }), offeredKinds: SESSION_CHAT_PILL_KINDS, context: this._session, actionRunner, @@ -844,6 +851,11 @@ export class SessionChatInputToolbar extends Disposable { * changes and status, resolving the owning session for provider gating and the * open-changes action. Clears any explicit {@link setSession} override. */ + /** Hides the Changes pill while `hidden`, such as while the input shows the session's changes as its working set. */ + setChangesHidden(hidden: IObservable): void { + this._changesHidden.set(hidden, undefined); + } + setChat(chat: IChat | undefined): void { this.setDebugData(undefined); this._sessionOverride.set('unset', undefined); diff --git a/src/vs/workbench/contrib/sessions/browser/chatEditor/chatEditor.ts b/src/vs/workbench/contrib/sessions/browser/chatEditor/chatEditor.ts index 0b71ec4ff18995..dfdb2730eb0bb4 100644 --- a/src/vs/workbench/contrib/sessions/browser/chatEditor/chatEditor.ts +++ b/src/vs/workbench/contrib/sessions/browser/chatEditor/chatEditor.ts @@ -53,6 +53,15 @@ export const SESSION_CHAT_VIEW_HOST_OPTIONS: ISessionViewHostOptions = { input: { showPills: true }, }; +/** + * How the chat of a chat window shows its session outside the Agents window, such as in an editor window: also with the + * session's changes as a working set above its input, as the editor window's chat shows them. + */ +export const SESSION_CHAT_VIEW_WORKING_SET_HOST_OPTIONS: ISessionViewHostOptions = { + ...SESSION_CHAT_VIEW_HOST_OPTIONS, + input: { showPills: true, workingSet: true }, +}; + /** What a chat editor needs of the window it shows in: how the window lays out its chat, and its main part. */ export interface IChatEditorWindow { readonly layoutMode: IObservable; @@ -74,6 +83,8 @@ export interface ISessionChatViewHost { readonly composer?: IObservable; /** The window the view shows in, once it shows. */ readonly window: IObservable; + /** How the view shows the session; {@link SESSION_CHAT_VIEW_HOST_OPTIONS} by default. */ + readonly hostOptions?: ISessionViewHostOptions; /** Whether its chat is the active one of its window: only that one shows the Session Activity Bar, also beside its window's chat. */ readonly active: IObservable; /** Called with what the editor shows once it is created, until the returned disposable is disposed. */ @@ -315,7 +326,7 @@ export class ChatEditorView extends Disposable implements IViewEditorView { trackActiveGroup(); })); const scopedInstantiationService = this._scopedServices.add(instantiationService.createChild(new ServiceCollection([IContextKeyService, scopedContextKeyService]))); - const sessionView = this.sessionView = this._register(scopedInstantiationService.createInstance(ChatEditorContent, SESSION_CHAT_VIEW_HOST_OPTIONS)); + const sessionView = this.sessionView = this._register(scopedInstantiationService.createInstance(ChatEditorContent, host?.hostOptions ?? SESSION_CHAT_VIEW_HOST_OPTIONS)); this.element.appendChild(sessionView.element); // Styled as the active session of the grid is. sessionView.element.classList.add('is-active'); diff --git a/src/vs/workbench/contrib/sessions/browser/parts/chatView.ts b/src/vs/workbench/contrib/sessions/browser/parts/chatView.ts index 42a93641c46c34..25df1c4f65a922 100644 --- a/src/vs/workbench/contrib/sessions/browser/parts/chatView.ts +++ b/src/vs/workbench/contrib/sessions/browser/parts/chatView.ts @@ -175,6 +175,11 @@ export interface IChatViewInputOptions { readonly minLines?: number; /** Whether the status pills of the session show above the input; `true` by default. */ readonly showPills?: boolean; + /** + * Whether the changes of an Agent Host session show as a working set above the input, with Keep and Undo, in place of + * its Changes pill, as the editor window's chat shows them. Never in the Agents window; `false` by default. + */ + readonly workingSet?: boolean; } /** From 3d817422b7016e7b23f672244bc769b22e41ddd3 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 04:25:04 +0100 Subject: [PATCH 008/165] chat: let a provided sessions list give the Chat view its composer of new chats While the Chat view shows a new chat, empty and without a title, it shows the composer of new chats of a provided sessions list in its place, at the height of its content below the sessions. Without one, the Chat view is as it is. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../widgetHosts/viewPane/chatViewPane.ts | 38 ++++++++++++++++++- .../viewPane/chatViewSessionsList.ts | 10 +++++ .../viewPane/media/chatViewPane.css | 20 ++++++++++ .../widgetHosts/viewPane/chatViewPane.test.ts | 28 ++++++++++++++ 4 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 1d432482594c26..dc9a2e5526f73c 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -69,7 +69,7 @@ import { ChatViewWelcomeController, IViewWelcomeDelegate } from '../../viewsWelc import { IChatViewsWelcomeDescriptor } from '../../viewsWelcome/chatViewsWelcome.js'; import { IWorkbenchLayoutService, LayoutSettings, Position } from '../../../../../services/layout/browser/layoutService.js'; import { AgentSessionsViewerOrientation, AgentSessionsViewerPosition } from '../../agentSessions/agentSessions.js'; -import { IChatViewSessionsList, IChatViewSessionsListControl, IChatViewSessionsListService } from './chatViewSessionsList.js'; +import { IChatViewSessionsComposer, IChatViewSessionsList, IChatViewSessionsListControl, IChatViewSessionsListService } from './chatViewSessionsList.js'; import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; import { CHAT_WIDGET_VIEW_STATE_CACHE_LIMIT, ChatViewId, IChatWidgetService, IChatWidgetViewState, setModelPreservingInputTypedWhileLoading } from '../../chat.js'; import { IActivityService, ProgressBadge } from '../../../../../services/activity/common/activity.js'; @@ -462,6 +462,18 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { // Chat Control const chatWidget = this.createChatControl(parent); + // Composer of new chats of a provided sessions list, in place of the chat while it is a new chat + const composer = this.providedSessionsList?.createComposer?.(append(parent, $('.chat-view-composer-container'))); + if (composer) { + this.providedComposer = this._register(composer); + this._register(Event.any(chatWidget.onDidChangeEmptyState, chatWidget.onDidChangeViewModel)(() => this.updateComposerVisibility())); + this._register(autorun(reader => { + composer.height.read(reader); + this.relayout(); + })); + this.updateComposerVisibility(); + } + // Controls Listeners this.registerControlsListeners(sessionsControl, chatWidget, welcomeController); @@ -868,6 +880,19 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private sessionsControl: IChatViewSessionsListControl | undefined; /** The sessions list a provider gives in place of the Chat view's own, if any. */ private providedSessionsList: IChatViewSessionsList | undefined; + /** The composer of new chats of {@link providedSessionsList}, if any, and whether it shows in place of the chat. */ + private providedComposer: IChatViewSessionsComposer | undefined; + private composerShown = false; + + /** Shows the provided composer in place of the chat while the chat is a new chat: empty, without a title. */ + private updateComposerVisibility(): void { + const shown = !!this.providedComposer && (!this._widget?.viewModel || (this._widget.isEmpty() && !this._widget.viewModel.model.title)); + if (shown !== this.composerShown) { + this.composerShown = shown; + this.viewPaneContainer?.classList.toggle('shows-composer', shown); + this.relayout(); + } + } get agentSessionsControl(): AgentSessionsControl | undefined { return this.sessionsControl instanceof AgentSessionsControl ? this.sessionsControl : undefined; } private sessionsListRevealCount = 0; @@ -1767,6 +1792,10 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } focusInput(): void { + if (this.composerShown && this.providedComposer) { + this.providedComposer.focus(); + return; + } this._widget.focusInput(); } @@ -1851,6 +1880,11 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { layoutChatWidgetForInputHeight(this._widget, inputMaxHeight, remainingHeight - heightReduction, remainingWidth - widthReduction); } + // Composer, in place of the chat + if (this.composerShown) { + this.providedComposer?.layout(remainingWidth - widthReduction, remainingHeight - heightReduction); + } + // Remember last dimensions per orientation this.lastDimensionsPerOrientation.set(this.sessionsViewerOrientation, { height, width }); } @@ -1927,7 +1961,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { let availableSessionsHeight = height - sessionsTitleHeight; let reservedChatWidgetHeight = 0; if (this.sessionsViewerOrientation === AgentSessionsViewerOrientation.Stacked) { - reservedChatWidgetHeight = sessionsOnly ? 0 : Math.max(ChatViewPane.MIN_CHAT_WIDGET_HEIGHT, this._widget?.input?.height.get() ?? 0); + reservedChatWidgetHeight = sessionsOnly ? 0 : this.composerShown && this.providedComposer ? this.providedComposer.height.get() : Math.max(ChatViewPane.MIN_CHAT_WIDGET_HEIGHT, this._widget?.input?.height.get() ?? 0); availableSessionsHeight -= reservedChatWidgetHeight; } else { availableSessionsHeight -= this.sessionsNewButtonContainer?.offsetHeight ?? 0; diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewSessionsList.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewSessionsList.ts index 1a1d49ee68d101..2608262efc79e2 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewSessionsList.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewSessionsList.ts @@ -28,6 +28,14 @@ export interface IChatViewSessionsListControl extends IDisposable { getFocus(): IAgentSession[]; } +/** A composer of new chats that the Chat view shows in place of its chat while it shows a new chat. */ +export interface IChatViewSessionsComposer extends IDisposable { + /** The height of its content, which the Chat view gives it at its bottom, below the sessions while they are stacked. */ + readonly height: IObservable; + layout(width: number, height: number): void; + focus(): void; +} + /** A sessions list of the Chat view, with how the Chat view shows it. */ export interface IChatViewSessionsList { readonly control: IChatViewSessionsListControl; @@ -35,6 +43,8 @@ export interface IChatViewSessionsList { readonly hasHeader: boolean; /** Whether the Chat view shows only the sessions, without its chat. */ readonly sessionsOnly: IObservable; + /** Creates, in `container`, the composer of new chats that the Chat view shows in place of its new chat. */ + createComposer?(container: HTMLElement): IChatViewSessionsComposer; } /** Gives the Chat view a sessions list in place of its own. */ diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css index 203758b0894a11..96fe5e8ffcbb56 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css @@ -240,3 +240,23 @@ .chat-viewpane.sessions-only .chat-controls-container { display: none; } + +/* A provided composer of new chats shows in place of the chat while the chat is a new chat */ +.chat-viewpane .chat-view-composer-container { + display: none; + flex: 1; + min-height: 0; + min-width: 0; +} + +.chat-viewpane.shows-composer { + + .chat-view-composer-container { + display: flex; + flex-direction: column; + } + + .chat-controls-container { + display: none; + } +} diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index 34e111a05e20b9..a066fd574657c1 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -98,6 +98,34 @@ suite('ChatViewPane sessions refresh', () => { assert.deepStrictEqual(visibility, [true, false]); }); + test('a provided composer shows in place of the chat while the chat is a new chat, and never without one', () => { + let empty = true; + let title = ''; + const host: { providedComposer: object | undefined; composerShown: boolean; viewPaneContainer: HTMLElement; _widget: object; relayout(): void } = { + providedComposer: {}, + composerShown: false, + viewPaneContainer: document.createElement('div'), + _widget: { get viewModel() { return { model: { title } }; }, isEmpty: () => empty }, + relayout: () => { }, + }; + const update = Reflect.get(ChatViewPane.prototype, 'updateComposerVisibility') as (this: typeof host) => void; + const states: boolean[] = []; + const step = () => { + update.call(host); + states.push(host.composerShown && host.viewPaneContainer.classList.contains('shows-composer')); + }; + step(); + empty = false; + title = 'A chat'; + step(); + empty = true; + title = ''; + step(); + const withoutComposer = { ...host, providedComposer: undefined, composerShown: false }; + update.call(withoutComposer); + assert.deepStrictEqual({ states, withoutComposer: withoutComposer.composerShown }, { states: [true, false, true], withoutComposer: false }); + }); + test('the Chat view takes the sessions list of the provider registered last, and its own once none is', () => { const service = new ChatViewSessionsListService(); const provider = (name: string) => upcastPartial({ create: () => { throw new Error(name); } }); From 0c6eab1880362f969cb0af9388a086fd310339c2 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 04:30:43 +0100 Subject: [PATCH 009/165] Editor window: offer the worktrees that agent host sessions work in as locations of the window's folder A window with a single local folder can now move that folder to the worktrees its local agent host sessions work in, behind the experimental setting chat.editorWindow.enableWorktree (off by default, experiment-controlled). - AgentHostWorktreeLocationProvider: the locations of a folder are the distinct working directories of the local agent host's sessions (not archived; local chats and extension sessions belong to the folder itself). A working directory counts when it is a linked checkout of the folder's git repository, as the editor window decides that a folder belongs to its repository, and when the repository's entry for it names it back, so a folder that only points into the repository does not inherit the folder's trust. Any such checkout resolves, also before the sessions load, to restore the location the folder was at. There is no discovery of the repository's worktrees. - It is registered as a built-in provider while the setting is on, not in the Agents window. - readFolderGitCheckout moves from WorkspaceFolderRepositories to services/workspaces/common, so that both can read a folder's checkout; WorkspaceFolderRepositories is unchanged otherwise. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostWorktreeLocations.ts | 188 ++++++++++++++++++ .../agentSessions.contribution.ts | 1 + .../contrib/chat/common/constants.ts | 5 + .../agentHostWorktreeLocations.test.ts | 113 +++++++++++ .../browser/workspaceFolderRepositories.ts | 39 +--- .../workspaces/common/gitFolderCheckout.ts | 50 +++++ 6 files changed, 360 insertions(+), 36 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts create mode 100644 src/vs/workbench/services/workspaces/common/gitFolderCheckout.ts diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts new file mode 100644 index 00000000000000..b05f054c5a45a6 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts @@ -0,0 +1,188 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../../base/common/network.js'; +import { isAbsolute } from '../../../../../../base/common/path.js'; +import { basename, extUriBiasedIgnorePathCase, joinPath } from '../../../../../../base/common/resources.js'; +import { isDefined } from '../../../../../../base/common/types.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { localize } from '../../../../../../nls.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../../../platform/configuration/common/configurationRegistry.js'; +import { FileOperationResult, IFileService, toFileOperationResult } from '../../../../../../platform/files/common/files.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { Registry } from '../../../../../../platform/registry/common/platform.js'; +import { IFolderLocation, IWorkspaceFolderLocationProvider, IWorkspaceFolderLocationService } from '../../../../../../platform/workspace/common/folderLocations.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../common/contributions.js'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; +import { readFolderGitCheckout } from '../../../../../services/workspaces/common/gitFolderCheckout.js'; +import { isLocalAgentHostTarget } from '../../../common/chatSessionsService.js'; +import { ChatConfiguration } from '../../../common/constants.js'; +import { IAgentSession } from '../agentSessionsModel.js'; +import { IAgentSessionsService } from '../agentSessionsService.js'; + +/** The id of the provider of the worktrees that the local agent host's sessions work in. */ +export const AGENT_HOST_WORKTREE_LOCATIONS_PROVIDER_ID = 'agentHost.sessionWorktrees'; + +/** + * The working directories of the local agent host's sessions in `sessions`, without duplicates: the folders their agents + * work in, such as a worktree a session created. Archived sessions and those of other providers, such as local chats, + * which belong to the window's primary folder, are left out. + */ +export function getAgentHostSessionWorkingDirectories(sessions: readonly IAgentSession[]): URI[] { + const result: URI[] = []; + for (const session of sessions) { + const path = session.metadata?.workingDirectoryPath; + if (!isLocalAgentHostTarget(session.providerType) || session.isArchived() || typeof path !== 'string' || !path) { + continue; + } + const directory = URI.file(path); + if (!result.some(candidate => extUriBiasedIgnorePathCase.isEqual(candidate, directory))) { + result.push(directory); + } + } + return result; +} + +/** + * The alternate locations of a local folder that its agent host sessions know: the worktrees of the folder's git + * repository that sessions work in. There is no discovery of the repository's worktrees; a session's working directory is + * one only when it is a linked checkout of that repository, as the editor window decides that a folder belongs to a + * window's repository (see `WorkspaceFolderRepositories`), and when that repository's entry for it names its `.git` back. + * That keeps a folder that only points into the repository from inheriting the primary's trust. + */ +export class AgentHostWorktreeLocationProvider implements IWorkspaceFolderLocationProvider { + + readonly id = AGENT_HOST_WORKTREE_LOCATIONS_PROVIDER_ID; + + constructor( + @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, + @IFileService private readonly _fileService: IFileService, + @ILogService private readonly _logService: ILogService, + ) { } + + async provideLocations(primary: URI, token: CancellationToken): Promise { + const repository = await this._readRepository(primary); + if (!repository || token.isCancellationRequested) { + return []; + } + const candidates = getAgentHostSessionWorkingDirectories(this._agentSessionsService.model.sessions) + .filter(directory => !extUriBiasedIgnorePathCase.isEqual(directory, primary)); + const locations = await Promise.all(candidates.map(candidate => this._resolve(repository, candidate))); + return token.isCancellationRequested ? [] : locations.filter(isDefined); + } + + async resolveLocation(primary: URI, location: URI, token: CancellationToken): Promise { + // Any linked checkout of the repository resolves, also before the sessions load, such as to restore the location + // that the window's folder was at + if (extUriBiasedIgnorePathCase.isEqual(location, primary)) { + return undefined; + } + const repository = await this._readRepository(primary); + if (!repository || token.isCancellationRequested) { + return undefined; + } + return this._resolve(repository, location); + } + + /** The git repository of the local folder `folder` is a checkout of, if any. */ + private async _readRepository(folder: URI): Promise { + if (folder.scheme !== Schemas.file) { + return undefined; + } + try { + return (await readFolderGitCheckout(this._fileService, folder))?.repository; + } catch (error) { + this._logService.warn(`[AgentHostWorktreeLocations] Could not read the git repository of ${folder.toString()}`, error); + return undefined; + } + } + + /** `candidate` as a location of `repository`'s folder, when it is a linked checkout of that repository. */ + private async _resolve(repository: URI, candidate: URI): Promise { + if (candidate.scheme !== Schemas.file) { + return undefined; + } + try { + const checkout = await readFolderGitCheckout(this._fileService, candidate); + if (!checkout?.worktreeGitDir || !extUriBiasedIgnorePathCase.isEqual(checkout.repository, repository) || !await this._namesBack(checkout.worktreeGitDir, candidate)) { + return undefined; + } + } catch (error) { + this._logService.warn(`[AgentHostWorktreeLocations] Could not read the git checkout of ${candidate.toString()}`, error); + return undefined; + } + return { uri: candidate, label: basename(candidate), icon: Codicon.gitBranchCheckout }; + } + + /** Whether the repository's entry for a worktree, `worktreeGitDir`, names the `.git` of `folder` back. */ + private async _namesBack(worktreeGitDir: URI, folder: URI): Promise { + let content: string; + try { + content = (await this._fileService.readFile(joinPath(worktreeGitDir, 'gitdir'))).value.toString().trim(); + } catch (error) { + if (error instanceof Error && toFileOperationResult(error) === FileOperationResult.FILE_NOT_FOUND) { + return false; + } + throw error; + } + return !!content && extUriBiasedIgnorePathCase.isEqual(isAbsolute(content) ? URI.file(content) : joinPath(worktreeGitDir, content), joinPath(folder, '.git')); + } +} + +/** Offers the worktrees of the agent host's sessions as locations of an editor window's folder, while the setting is on. */ +export class AgentHostWorktreeLocationsContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.agentHostWorktreeLocations'; + + private readonly _registration = this._register(new MutableDisposable()); + + constructor( + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IWorkspaceFolderLocationService private readonly _folderLocationService: IWorkspaceFolderLocationService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, + ) { + super(); + if (environmentService.isSessionsWindow) { + return; + } + this._update(); + this._register(_configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ChatConfiguration.EditorWindowEnableWorktree)) { + this._update(); + } + })); + } + + private _update(): void { + if (!this._configurationService.getValue(ChatConfiguration.EditorWindowEnableWorktree)) { + this._registration.clear(); + } else if (!this._registration.value) { + this._registration.value = this._folderLocationService.registerProvider(this._instantiationService.createInstance(AgentHostWorktreeLocationProvider), { builtIn: true }); + } + } +} + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + id: 'chatSidebar', + title: localize('chatConfigurationTitle', "Chat"), + type: 'object', + properties: { + [ChatConfiguration.EditorWindowEnableWorktree]: { + type: 'boolean', + default: false, + tags: ['experimental'], + experiment: { mode: 'auto' }, + markdownDescription: localize('chat.editorWindow.enableWorktree', "Controls whether a window with a single local folder can move that folder to the worktrees its agent host sessions work in, and back to the folder's own location."), + }, + }, +}); + +registerWorkbenchContribution2(AgentHostWorktreeLocationsContribution.ID, AgentHostWorktreeLocationsContribution, WorkbenchPhase.BlockRestore); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts index 9fd8154696b322..506c70e08cb0c8 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts @@ -21,6 +21,7 @@ import { AgentHostPermissionUiContribution } from './agentHost/agentHostPermissi import { registerExternalSessionsFilterMenu } from './externalSessionsFilterMenu.js'; import './agentHost/agentHostChatInputPicker.contribution.js'; import './agentHost/agentHostModeSynchronizer.js'; +import './agentHost/agentHostWorktreeLocations.js'; import { ChatSessionArchiveActionWordingSettingId, getChatSessionArchiveActionWording } from '../../../../../platform/chat/common/sessionArchiveActions.js'; import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 8ef96a99bf1bc3..134e29231a4374 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -52,6 +52,11 @@ export enum ChatConfiguration { AgentsPickerInAttachContextMenu = 'sessions.chat.experimental.agentsPickerInAttachContextMenu', /** Desktop only: lets chat attach a screenshot of the window of another app. */ AppWindowScreenshotsEnabled = 'chat.appWindowScreenshots.enabled', + /** + * Lets an editor window with one local folder move that folder between its primary location and the worktrees its + * agent host sessions work in, and offer them when starting a session. + */ + EditorWindowEnableWorktree = 'chat.editorWindow.enableWorktree', PlanAgentDefaultModel = 'chat.planAgent.defaultModel', ExploreAgentDefaultModel = 'chat.exploreAgent.defaultModel', UtilityModel = 'chat.utilityModel', diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts new file mode 100644 index 00000000000000..9fa274f2adaed0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { VSBuffer } from '../../../../../../../base/common/buffer.js'; +import { CancellationToken } from '../../../../../../../base/common/cancellation.js'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { FileOperationError, FileOperationResult, IFileContent, IFileService, IFileStatWithMetadata } from '../../../../../../../platform/files/common/files.js'; +import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../../../platform/log/common/log.js'; +import { AgentHostWorktreeLocationProvider, getAgentHostSessionWorkingDirectories } from '../../../../browser/agentSessions/agentHost/agentHostWorktreeLocations.js'; +import { IAgentSession } from '../../../../browser/agentSessions/agentSessionsModel.js'; +import { IAgentSessionsService } from '../../../../browser/agentSessions/agentSessionsService.js'; +import { SessionType } from '../../../../common/chatSessionsService.js'; + +suite('AgentHostWorktreeLocations', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function session(providerType: string, workingDirectoryPath: string | undefined, archived = false): IAgentSession { + return upcastPartial({ providerType, metadata: workingDirectoryPath ? { workingDirectoryPath } : undefined, isArchived: () => archived }); + } + + /** + * A provider over git entries: `true` is a `.git` directory, and a string the content of a file, such as a worktree's + * `.git` file or its entry's `gitdir` file in the repository. + */ + function createProvider(entries: Record, sessions: readonly IAgentSession[]) { + const fileService = upcastPartial({ + stat: async (resource: URI) => { + const entry = entries[resource.path]; + if (entry === undefined) { + throw new FileOperationError('not found', FileOperationResult.FILE_NOT_FOUND); + } + return upcastPartial({ isDirectory: entry === true }); + }, + readFile: async (resource: URI) => { + const entry = entries[resource.path]; + if (typeof entry !== 'string') { + throw new FileOperationError('not found', FileOperationResult.FILE_NOT_FOUND); + } + return upcastPartial({ value: VSBuffer.fromString(entry) }); + }, + }); + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IFileService, fileService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IAgentSessionsService, upcastPartial({ model: upcastPartial({ sessions: [...sessions] }) })); + return instantiationService.createInstance(AgentHostWorktreeLocationProvider); + } + + const repository = { + '/work/vscode/.git': true, + // A worktree whose repository entry names it back + '/work/vscode.worktrees/feature/.git': 'gitdir: /work/vscode/.git/worktrees/feature\n', + '/work/vscode/.git/worktrees/feature/gitdir': '/work/vscode.worktrees/feature/.git\n', + // One with a relative link back + '/work/vscode.worktrees/relative/.git': 'gitdir: /work/vscode/.git/worktrees/relative', + '/work/vscode/.git/worktrees/relative/gitdir': '../../../../vscode.worktrees/relative/.git', + // A folder that points into the repository, but that the repository does not name back + '/work/impostor/.git': 'gitdir: /work/vscode/.git/worktrees/feature', + // A worktree of another repository + '/work/tools/.git': true, + '/work/tools.worktrees/fix/.git': 'gitdir: /work/tools/.git/worktrees/fix', + '/work/tools/.git/worktrees/fix/gitdir': '/work/tools.worktrees/fix/.git', + }; + + test('the working directories of the local agent host\'s sessions, without duplicates, archived sessions or other providers\' sessions', () => { + const directories = getAgentHostSessionWorkingDirectories([ + session(SessionType.AgentHostCopilot, '/work/vscode.worktrees/feature'), + session(SessionType.AgentHostClaude, '/work/vscode.worktrees/feature'), + session(SessionType.AgentHostCopilot, '/work/vscode.worktrees/archived', true), + session(SessionType.Local, '/work/vscode.worktrees/local'), + session(SessionType.CopilotCLI, '/work/vscode.worktrees/extension'), + session(SessionType.AgentHostCopilot, undefined), + session(SessionType.AgentHostCopilot, '/work/vscode'), + ]); + assert.deepStrictEqual(directories.map(directory => directory.path), ['/work/vscode.worktrees/feature', '/work/vscode']); + }); + + test('offers the worktrees of the folder\'s repository that sessions work in, which the repository names back, and resolves any of them', async () => { + const provider = createProvider(repository, [ + session(SessionType.AgentHostCopilot, '/work/vscode.worktrees/feature'), + session(SessionType.AgentHostCopilot, '/work/vscode.worktrees/relative'), + session(SessionType.AgentHostCopilot, '/work/vscode'), + session(SessionType.AgentHostCopilot, '/work/impostor'), + session(SessionType.AgentHostCopilot, '/work/tools.worktrees/fix'), + session(SessionType.AgentHostCopilot, '/work/missing'), + session(SessionType.Local, '/work/vscode.worktrees/local'), + ]); + const resolve = async (location: string) => (await provider.resolveLocation(URI.file('/work/vscode'), URI.file(location), CancellationToken.None))?.label; + + assert.deepStrictEqual({ + locations: (await provider.provideLocations(URI.file('/work/vscode'), CancellationToken.None)).map(location => ({ path: location.uri.path, label: location.label })), + ofAFolderWithoutRepository: await provider.provideLocations(URI.file('/work/plain'), CancellationToken.None), + // A location that no session lists any more still resolves, as to restore it + resolved: await resolve('/work/vscode.worktrees/relative'), + impostor: await resolve('/work/impostor'), + otherRepository: await resolve('/work/tools.worktrees/fix'), + primary: await resolve('/work/vscode'), + }, { + locations: [{ path: '/work/vscode.worktrees/feature', label: 'feature' }, { path: '/work/vscode.worktrees/relative', label: 'relative' }], + ofAFolderWithoutRepository: [], + resolved: 'relative', + impostor: undefined, + otherRepository: undefined, + primary: undefined, + }); + }); +}); diff --git a/src/vs/workbench/contrib/sessions/browser/workspaceFolderRepositories.ts b/src/vs/workbench/contrib/sessions/browser/workspaceFolderRepositories.ts index a46d51811d4f78..76839f211c1837 100644 --- a/src/vs/workbench/contrib/sessions/browser/workspaceFolderRepositories.ts +++ b/src/vs/workbench/contrib/sessions/browser/workspaceFolderRepositories.ts @@ -7,8 +7,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { Schemas } from '../../../../base/common/network.js'; import { IObservable, observableFromEvent, observableValue, transaction } from '../../../../base/common/observable.js'; -import { isAbsolute } from '../../../../base/common/path.js'; -import { basename, dirname, isEqual, joinPath } from '../../../../base/common/resources.js'; +import { isEqual, joinPath } from '../../../../base/common/resources.js'; import { isDefined } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { FileOperationResult, IFileService, toFileOperationResult } from '../../../../platform/files/common/files.js'; @@ -16,6 +15,7 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { ISessionsListingScope } from '../common/sessionsProvider.js'; import { parseGitHubRepositoryFromGitConfig } from './gitHubRepositoryResolver.js'; +import { readFolderGitCheckout } from '../../../services/workspaces/common/gitFolderCheckout.js'; /** The hosts of the GitHub repositories that Copilot Cloud sessions work in (`github-remote-file://github/...`). */ const GITHUB_HOSTS = ['github.com']; @@ -111,7 +111,7 @@ export class WorkspaceFolderRepositories extends Disposable implements ISessions /** The checkout of `folder`, and whether to read it again as the folders change, as its `origin` could not be read. */ private async _readCheckout(folder: URI): Promise<{ readonly checkout: IFolderCheckout; readonly readAgain: boolean }> { - const repository = await WorkspaceFolderRepositories._readFolderGitRepository(this._fileService, folder); + const repository = (await readFolderGitCheckout(this._fileService, folder))?.repository; if (!repository) { return { checkout: { repository, gitHubRepository: undefined }, readAgain: false }; } @@ -124,39 +124,6 @@ export class WorkspaceFolderRepositories extends Disposable implements ISessions } } - /** - * The git repository that a local folder is a checkout of, read from its `.git`: the folder itself when `.git` is a - * directory, and the main checkout when `.git` is the file of a worktree (`gitdir: /.git/worktrees/`). - * `undefined` for a folder without `.git`, or whose `.git` names no worktree, such as a submodule's. Throws when `.git` - * exists but cannot be read. - */ - private static async _readFolderGitRepository(fileService: IFileService, folder: URI): Promise { - const dotGit = joinPath(folder, '.git'); - let isDirectory: boolean; - try { - isDirectory = (await fileService.stat(dotGit)).isDirectory; - } catch (error) { - if (error instanceof Error && toFileOperationResult(error) === FileOperationResult.FILE_NOT_FOUND) { - return undefined; - } - throw error; - } - if (isDirectory) { - return folder; - } - const content = (await fileService.readFile(dotGit)).value.toString(); - const gitdir = /^gitdir:\s*(?.+?)\s*$/m.exec(content)?.groups?.path; - if (!gitdir) { - return undefined; - } - const worktreeGitDir = isAbsolute(gitdir) ? URI.file(gitdir) : joinPath(folder, gitdir); - const worktreesDir = dirname(worktreeGitDir); - const commonGitDir = dirname(worktreesDir); - return basename(worktreesDir) === 'worktrees' && basename(commonGitDir) === '.git' && !isEqual(commonGitDir, worktreesDir) - ? dirname(commonGitDir) - : undefined; - } - /** * The GitHub repository, as lowercase `owner/repo`, that the `origin` remote of a git `config` names, such as * `https://github.com/owner/repo.git` or `git@github.com:owner/repo`; `undefined` for another host or without `origin`. diff --git a/src/vs/workbench/services/workspaces/common/gitFolderCheckout.ts b/src/vs/workbench/services/workspaces/common/gitFolderCheckout.ts new file mode 100644 index 00000000000000..396815f54f41f1 --- /dev/null +++ b/src/vs/workbench/services/workspaces/common/gitFolderCheckout.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isAbsolute } from '../../../../base/common/path.js'; +import { basename, dirname, isEqual, joinPath } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { FileOperationResult, IFileService, toFileOperationResult } from '../../../../platform/files/common/files.js'; + +/** What a local folder is a checkout of, read from its `.git` (see {@link readFolderGitCheckout}). */ +export interface IFolderGitCheckout { + /** The git repository: the folder itself, or the main checkout of a worktree. */ + readonly repository: URI; + /** For a worktree, its entry in the repository's `.git/worktrees`, which its `.git` file names. */ + readonly worktreeGitDir?: URI; +} + +/** + * The git repository that a local folder is a checkout of, read from its `.git`: the folder itself when `.git` is a + * directory, and the main checkout when `.git` is the file of a worktree (`gitdir: /.git/worktrees/`). + * `undefined` for a folder without `.git`, or whose `.git` names no worktree, such as a submodule's. Throws when `.git` + * exists but cannot be read. + */ +export async function readFolderGitCheckout(fileService: IFileService, folder: URI): Promise { + const dotGit = joinPath(folder, '.git'); + let isDirectory: boolean; + try { + isDirectory = (await fileService.stat(dotGit)).isDirectory; + } catch (error) { + if (error instanceof Error && toFileOperationResult(error) === FileOperationResult.FILE_NOT_FOUND) { + return undefined; + } + throw error; + } + if (isDirectory) { + return { repository: folder }; + } + const content = (await fileService.readFile(dotGit)).value.toString(); + const gitdir = /^gitdir:\s*(?.+?)\s*$/m.exec(content)?.groups?.path; + if (!gitdir) { + return undefined; + } + const worktreeGitDir = isAbsolute(gitdir) ? URI.file(gitdir) : joinPath(folder, gitdir); + const worktreesDir = dirname(worktreeGitDir); + const commonGitDir = dirname(worktreesDir); + return basename(worktreesDir) === 'worktrees' && basename(commonGitDir) === '.git' && !isEqual(commonGitDir, worktreesDir) + ? { repository: dirname(commonGitDir), worktreeGitDir } + : undefined; +} From 23e67daa8efe24abbb139e56dc3e915d1050b943 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 04:40:40 +0100 Subject: [PATCH 010/165] Editor window: move the window's folder to where the chat that the user opens works Behind the experimental setting chat.editorWindow.enableWorktree, a window with a single local folder moves that folder to where the chat that the user brings up works: the working directory of a local agent host session, such as a worktree, and back to the folder's own location for a local chat. - IChatViewHostService.shownSession (with IChatViewHost.shownSession and IChatViewHostShownSession): the chat that the host in use shows, and whether the user brought it up ('open') or the host restored it ('restore'). The Chat view reports it: 'restore' for the chat it restores, 'open' for one picked in its sessions list, a new chat, and a new chat that its first request made real. A host that replaces the Chat view reports its own, or none. - AgentHostWorktreeFollowContribution follows 'open' only: a local agent host session moves the folder to its working directory, once the sessions list has it; a local chat moves it back to the folder's own location; other chats, untitled chats and restored chats move nothing. It logs the setting's experiment trigger where the folder would move, with the setting on or off, and moves it only with the setting on. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostWorktreeFollow.ts | 111 ++++++++++++++++ .../agentSessions.contribution.ts | 1 + .../contrib/chat/browser/chatViewHost.ts | 16 +++ .../viewPane/chatViewHostService.ts | 24 +++- .../widgetHosts/viewPane/chatViewPane.ts | 17 ++- .../agentHost/agentHostWorktreeFollow.test.ts | 122 ++++++++++++++++++ .../viewPane/chatViewHostService.test.ts | 42 +++++- 7 files changed, 327 insertions(+), 6 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFollow.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFollow.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFollow.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFollow.ts new file mode 100644 index 00000000000000..d5752bb25a7e23 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFollow.ts @@ -0,0 +1,111 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../../../base/common/observable.js'; +import { extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { logSettingExperimentTrigger } from '../../../../../../platform/telemetry/common/experimentTrigger.js'; +import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { IWorkspaceFolderLocationService } from '../../../../../../platform/workspace/common/folderLocations.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../common/contributions.js'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; +import { IWorkspaceEditingService } from '../../../../../services/workspaces/common/workspaceEditing.js'; +import { isLocalAgentHostTarget } from '../../../common/chatSessionsService.js'; +import { ChatConfiguration } from '../../../common/constants.js'; +import { isUntitledChatSession, LocalChatSessionUri } from '../../../common/model/chatUri.js'; +import { IChatViewHostService } from '../../chatViewHost.js'; +import { IAgentSessionsService } from '../agentSessionsService.js'; + +/** + * Moves the folder of a window with a single local folder to where the chat that the user brings up works, while + * `chat.editorWindow.enableWorktree` is on: to the working directory of a local agent host session, such as a worktree, and + * back to the folder's own location for a local chat. A chat that the view restores, such as as the window starts, moves + * nothing; nor does a chat of another kind, such as a cloud session. + */ +export class AgentHostWorktreeFollowContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.agentHostWorktreeFollow'; + + /** The agent host session that the user brought up, until its working directory is known. */ + private _pending: URI | undefined; + /** Waits for the working directory of {@link _pending} as the sessions update. */ + private readonly _sessionsListener = this._register(new MutableDisposable()); + + constructor( + @IChatViewHostService chatViewHostService: IChatViewHostService, + @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IWorkspaceFolderLocationService private readonly _folderLocationService: IWorkspaceFolderLocationService, + @IWorkspaceEditingService private readonly _workspaceEditingService: IWorkspaceEditingService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + @ILogService private readonly _logService: ILogService, + @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, + ) { + super(); + if (environmentService.isSessionsWindow) { + return; + } + this._register(autorun(reader => { + const shown = chatViewHostService.shownSession.read(reader); + this._pending = undefined; + this._sessionsListener.clear(); + if (shown?.reason === 'open') { + this._follow(shown.resource); + } + })); + } + + /** Moves the window's folder to where the chat of `resource` works, once that is known. */ + private _follow(resource: URI): void { + const folder = this._workspaceContextService.getWorkspace().folders[0]; + const primary = this._workspaceContextService.getWorkspace().uri; + if (!folder || !primary || !this._folderLocationService.canSetLocation(folder) || isUntitledChatSession(resource)) { + return; + } + + let target: URI; + if (LocalChatSessionUri.isLocalSession(resource)) { + target = primary; + } else if (isLocalAgentHostTarget(resource.scheme)) { + const path = this._agentSessionsService.getSession(resource)?.metadata?.workingDirectoryPath; + if (typeof path !== 'string' || !path) { + // Not listed with its working directory yet, such as a session that its first request just started + this._pending = resource; + this._sessionsListener.value ??= this._agentSessionsService.model.onDidChangeSessions(() => { + if (this._pending) { + const pending = this._pending; + this._pending = undefined; + this._sessionsListener.clear(); + this._follow(pending); + } + }); + return; + } + target = URI.file(path); + } else { + return; + } + + if (extUriBiasedIgnorePathCase.isEqual(target, folder.uri)) { + return; + } + // The folder would move here: the same moment with the setting on and off + logSettingExperimentTrigger(this._telemetryService, ChatConfiguration.EditorWindowEnableWorktree); + if (!this._configurationService.getValue(ChatConfiguration.EditorWindowEnableWorktree)) { + return; + } + this._workspaceEditingService.setFolderLocation(target, { reason: 'user', folder: folder.uri }).catch(error => { + // Such as a working directory that is not a location of the folder + this._logService.trace(`[AgentHostWorktreeFollow] Did not move the folder to ${target.toString()}`, error); + }); + } +} + +registerWorkbenchContribution2(AgentHostWorktreeFollowContribution.ID, AgentHostWorktreeFollowContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts index 506c70e08cb0c8..e4ecf19fe64d21 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts @@ -21,6 +21,7 @@ import { AgentHostPermissionUiContribution } from './agentHost/agentHostPermissi import { registerExternalSessionsFilterMenu } from './externalSessionsFilterMenu.js'; import './agentHost/agentHostChatInputPicker.contribution.js'; import './agentHost/agentHostModeSynchronizer.js'; +import './agentHost/agentHostWorktreeFollow.js'; import './agentHost/agentHostWorktreeLocations.js'; import { ChatSessionArchiveActionWordingSettingId, getChatSessionArchiveActionWording } from '../../../../../platform/chat/common/sessionArchiveActions.js'; import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; diff --git a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts index fc6589af2b9484..263fab9d362773 100644 --- a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts +++ b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { IObservable } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { SessionTypeSelectionReason } from '../common/constants.js'; @@ -11,6 +12,16 @@ import { IChatWidget } from './chat.js'; export const IChatViewHostService = createDecorator('chatViewHostService'); +/** The chat that a host shows ({@link IChatViewHost.shownSession}), and why it shows it. */ +export interface IChatViewHostShownSession { + readonly resource: URI; + /** + * `open` when the user brought the chat up, such as by picking it in the sessions list, starting a new chat or sending + * the first request of one; `restore` when the host shows the chat it last showed, such as as the window starts. + */ + readonly reason: 'open' | 'restore'; +} + /** How {@link IChatViewHost.reveal} shows the host. */ export interface IChatViewHostRevealOptions { /** @@ -70,6 +81,8 @@ export interface IChatViewHost { toggle(): Promise; /** Starts showing the session of `sessionResource`, which its caller may send a first request to. */ startSession(sessionResource: URI, options: IChatViewHostSessionStartOptions): Promise; + /** The chat the host shows; a host that does not implement it reports none. */ + readonly shownSession?: IObservable; } /** @@ -81,6 +94,9 @@ export interface IChatViewHostService extends IChatViewHost { revealIfShowing(sessionResource: URI, options?: IChatViewHostOpenOptions): Promise; + /** The chat that the host in use shows. */ + readonly shownSession: IObservable; + /** * Replaces the Chat view with `host` until the returned disposable is disposed. The host registered last is the one in * use; disposing it gives the use back to the one before. diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts index d433fe430427aa..b3358545b032f1 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts @@ -5,6 +5,7 @@ import { DeferredPromise, raceTimeout } from '../../../../../../base/common/async.js'; import { DisposableStore, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { derived, IObservable, ISettableObservable, observableFromEvent, observableValue } from '../../../../../../base/common/observable.js'; import { isEqual } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -13,7 +14,7 @@ import { IViewDescriptorService, ViewContainerLocation } from '../../../../../co import { IWorkbenchLayoutService, Parts } from '../../../../../services/layout/browser/layoutService.js'; import { IViewsService } from '../../../../../services/views/common/viewsService.js'; import { ChatViewId, IChatWidget } from '../../chat.js'; -import { IChatViewHost, IChatViewHostOpenOptions, IChatViewHostRevealOptions, IChatViewHostService, IChatViewHostSessionStart, IChatViewHostSessionStartOptions } from '../../chatViewHost.js'; +import { IChatViewHost, IChatViewHostOpenOptions, IChatViewHostRevealOptions, IChatViewHostService, IChatViewHostSessionStart, IChatViewHostSessionStartOptions, IChatViewHostShownSession } from '../../chatViewHost.js'; import { ChatViewPane } from './chatViewPane.js'; /** The Chat view, as the host of its chats. */ @@ -27,7 +28,13 @@ export class ChatViewPaneHost implements IChatViewHost { @IViewDescriptorService private readonly viewDescriptorService: IViewDescriptorService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IProgressService private readonly progressService: IProgressService, - ) { } + ) { + // The view exists once it first shows; it keeps its chat while hidden + const view = observableFromEvent(this, viewsService.onDidChangeViewVisibility, () => viewsService.getViewWithId(ChatViewId)); + this.shownSession = derived(this, reader => view.read(reader)?.shownSession.read(reader)); + } + + readonly shownSession: IObservable; async reveal(preserveFocus?: boolean, options?: IChatViewHostRevealOptions): Promise { const view = await this.viewsService.openView(ChatViewId, !preserveFocus); @@ -130,28 +137,39 @@ export class ChatViewHostService implements IChatViewHostService { private readonly chatViewHost: IChatViewHost; /** The registrations of the hosts that replace the Chat view, the one in use last: one each, also of the same host. */ private readonly registrations: { readonly host: IChatViewHost }[] = []; + private readonly hostInUse: ISettableObservable; + + readonly shownSession: IObservable; constructor( @IInstantiationService instantiationService: IInstantiationService, ) { this.chatViewHost = instantiationService.createInstance(ChatViewPaneHost); + this.hostInUse = observableValue(this, this.chatViewHost); + this.shownSession = derived(this, reader => this.hostInUse.read(reader).shownSession?.read(reader)); } private get host(): IChatViewHost { - return this.registrations.at(-1)?.host ?? this.chatViewHost; + return this.hostInUse.get(); } registerHost(host: IChatViewHost): IDisposable { const registration = { host }; this.registrations.push(registration); + this.updateHostInUse(); return toDisposable(() => { const index = this.registrations.indexOf(registration); if (index >= 0) { this.registrations.splice(index, 1); + this.updateHostInUse(); } }); } + private updateHostInUse(): void { + this.hostInUse.set(this.registrations.at(-1)?.host ?? this.chatViewHost, undefined); + } + reveal(preserveFocus?: boolean, options?: IChatViewHostRevealOptions): Promise { return this.host.reveal(preserveFocus, options); } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index ff50f904ca9847..43fa62ff15327f 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -71,6 +71,7 @@ import { IWorkbenchLayoutService, LayoutSettings, Position } from '../../../../. import { AgentSessionsViewerOrientation, AgentSessionsViewerPosition } from '../../agentSessions/agentSessions.js'; import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; import { CHAT_WIDGET_VIEW_STATE_CACHE_LIMIT, ChatViewId, IChatWidgetService, IChatWidgetViewState, setModelPreservingInputTypedWhileLoading } from '../../chat.js'; +import { IChatViewHostShownSession } from '../../chatViewHost.js'; import { IActivityService, ProgressBadge } from '../../../../../services/activity/common/activity.js'; import { DeferredPromise, disposableTimeout, raceCancellation, RunOnceScheduler } from '../../../../../../base/common/async.js'; import { AgentSessionsFilter, AgentSessionsGrouping } from '../../agentSessions/agentSessionsFilter.js'; @@ -169,6 +170,11 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private readonly activityBadge = this._register(new MutableDisposable()); private readonly _currentSessionResource = observableValue(this, undefined); + /** Whether the view restores the chat it last showed, rather than showing one that the user brought up. */ + private _restoringModel = false; + private readonly _shownSession = observableValue(this, undefined); + /** The chat the view shows, and whether it shows it as the user brought it up or as it restored it. */ + readonly shownSession: IObservable = this._shownSession; private readonly customizationMigrationsAvailable = observableValue(this, false); /** * Session resource of the last-focused chat widget, or this pane's own @@ -378,8 +384,10 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { try { this._widget.setVisible(false); + this._restoringModel = true; await this.showModel(cts.token, session.modelRef, true, !session.modelRef, undefined, session.localFallbackSelectionReason); } finally { + this._restoringModel = false; this._widget.setVisible(wasVisible); } }).catch(error => { @@ -1246,6 +1254,8 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { const model = chatWidget.viewModel?.model; this.titleControl?.update(model); this._currentSessionResource.set(chatWidget.viewModel?.sessionResource, undefined); + const shownResource = chatWidget.viewModel?.sessionResource; + this._shownSession.set(shownResource && { resource: shownResource, reason: this._restoringModel ? 'restore' : 'open' }, undefined); if (this.sessionsViewerOrientation === AgentSessionsViewerOrientation.Stacked) { return; // only reveal in side-by-side mode @@ -1385,7 +1395,12 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private async _applyModel(token: CancellationToken): Promise { const session = await this.acquireTransferredOrPersistedSession(token, 'ChatViewPane#applyModel'); - await this.showModel(token, session.modelRef, true, !session.modelRef, undefined, session.localFallbackSelectionReason); + this._restoringModel = true; + try { + await this.showModel(token, session.modelRef, true, !session.modelRef, undefined, session.localFallbackSelectionReason); + } finally { + this._restoringModel = false; + } } /** diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFollow.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFollow.test.ts new file mode 100644 index 00000000000000..c91abc2056add5 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFollow.test.ts @@ -0,0 +1,122 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Emitter } from '../../../../../../../base/common/event.js'; +import { observableValue } from '../../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../../../platform/log/common/log.js'; +import { ITelemetryService } from '../../../../../../../platform/telemetry/common/telemetry.js'; +import { TestExperimentTriggerTelemetryService } from '../../../../../../../platform/telemetry/test/common/experimentTriggerTestUtils.js'; +import { IWorkspaceFolderLocationService } from '../../../../../../../platform/workspace/common/folderLocations.js'; +import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../../platform/workspace/common/workspace.js'; +import { IWorkbenchEnvironmentService } from '../../../../../../services/environment/common/environmentService.js'; +import { IWorkspaceEditingService } from '../../../../../../services/workspaces/common/workspaceEditing.js'; +import { AgentHostWorktreeFollowContribution } from '../../../../browser/agentSessions/agentHost/agentHostWorktreeFollow.js'; +// The setting the follow reads and logs its trigger for +import '../../../../browser/agentSessions/agentHost/agentHostWorktreeLocations.js'; +import { IAgentSession } from '../../../../browser/agentSessions/agentSessionsModel.js'; +import { IAgentSessionsService } from '../../../../browser/agentSessions/agentSessionsService.js'; +import { IChatViewHostService, IChatViewHostShownSession } from '../../../../browser/chatViewHost.js'; +import { SessionType } from '../../../../common/chatSessionsService.js'; +import { ChatConfiguration } from '../../../../common/constants.js'; +import { LocalChatSessionUri } from '../../../../common/model/chatUri.js'; + +suite('AgentHostWorktreeFollow', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + const primary = URI.file('/work/vscode'); + const worktree = URI.file('/work/vscode.worktrees/feature'); + + /** The follow in a window whose folder is at `at`, recording where it moves the folder and its experiment triggers. */ + function createFollow(options: { readonly enabled: boolean; readonly at?: URI; readonly canSetLocation?: boolean }) { + const shownSession = observableValue('shownSession', undefined); + const workingDirectories = new Map(); + const onDidChangeSessions = store.add(new Emitter()); + const moves: string[] = []; + const telemetryService = new TestExperimentTriggerTelemetryService(); + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IChatViewHostService, upcastPartial({ shownSession })); + instantiationService.stub(IAgentSessionsService, upcastPartial({ + getSession: (resource: URI) => { + const path = workingDirectories.get(resource.toString()); + return path ? upcastPartial({ metadata: { workingDirectoryPath: path } }) : undefined; + }, + model: upcastPartial({ onDidChangeSessions: onDidChangeSessions.event }), + })); + instantiationService.stub(IConfigurationService, new TestConfigurationService({ [ChatConfiguration.EditorWindowEnableWorktree]: options.enabled })); + instantiationService.stub(IWorkspaceContextService, upcastPartial({ + getWorkspace: () => upcastPartial({ uri: primary, folders: [upcastPartial({ uri: options.at ?? primary })] }), + })); + instantiationService.stub(IWorkspaceFolderLocationService, upcastPartial({ canSetLocation: () => options.canSetLocation ?? true })); + instantiationService.stub(IWorkspaceEditingService, upcastPartial({ + setFolderLocation: async (location: URI, moveOptions?: { readonly reason?: string }) => { moves.push(`${location.path} (${moveOptions?.reason})`); }, + })); + instantiationService.stub(ITelemetryService, telemetryService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IWorkbenchEnvironmentService, upcastPartial({ isSessionsWindow: false })); + store.add(instantiationService.createInstance(AgentHostWorktreeFollowContribution)); + const show = (resource: URI, reason: IChatViewHostShownSession['reason'] = 'open') => shownSession.set({ resource, reason }, undefined); + return { show, workingDirectories, onDidChangeSessions, moves, triggers: telemetryService.triggers }; + } + + const agentSession = URI.from({ scheme: SessionType.AgentHostCopilot, path: '/session-1' }); + const localChat = LocalChatSessionUri.forSession('local-1'); + + test('moves the folder to the working directory of an agent host session the user opens, and back for a local chat, but not for a restored chat', () => { + const { show, workingDirectories, moves, triggers } = createFollow({ enabled: true }); + workingDirectories.set(agentSession.toString(), worktree.fsPath); + show(agentSession, 'restore'); + const restored = [...moves]; + show(agentSession); + const opened = [...moves]; + + const atWorktree = createFollow({ enabled: true, at: worktree }); + atWorktree.show(localChat); + atWorktree.show(URI.from({ scheme: SessionType.CopilotCloud, path: '/cloud-1' })); + + assert.deepStrictEqual({ restored, opened, triggers, local: atWorktree.moves }, { + restored: [], + opened: ['/work/vscode.worktrees/feature (user)'], + triggers: [`config.${ChatConfiguration.EditorWindowEnableWorktree}`], + local: ['/work/vscode (user)'], + }); + }); + + test('waits for the working directory of a session that the list does not have yet, and moves nothing where the folder already is', () => { + const { show, workingDirectories, onDidChangeSessions, moves } = createFollow({ enabled: true }); + show(agentSession); + const beforeListed = [...moves]; + workingDirectories.set(agentSession.toString(), worktree.fsPath); + onDidChangeSessions.fire(); + const listed = [...moves]; + show(localChat); + + assert.deepStrictEqual({ beforeListed, listed, localAtPrimary: moves }, { + beforeListed: [], + listed: ['/work/vscode.worktrees/feature (user)'], + localAtPrimary: ['/work/vscode.worktrees/feature (user)'], + }); + }); + + test('with the setting off, logs the same trigger but moves nothing; in a window whose folder cannot move, does neither', () => { + const off = createFollow({ enabled: false }); + off.workingDirectories.set(agentSession.toString(), worktree.fsPath); + off.show(agentSession); + const notMovable = createFollow({ enabled: true, canSetLocation: false }); + notMovable.workingDirectories.set(agentSession.toString(), worktree.fsPath); + notMovable.show(agentSession); + + assert.deepStrictEqual({ off: { moves: off.moves, triggers: off.triggers }, notMovable: { moves: notMovable.moves, triggers: notMovable.triggers } }, { + off: { moves: [], triggers: [`config.${ChatConfiguration.EditorWindowEnableWorktree}`] }, + notMovable: { moves: [], triggers: [] }, + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts index 25db610ef256bf..4408658d2fb144 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts @@ -5,7 +5,9 @@ import assert from 'assert'; import { DeferredPromise, timeout } from '../../../../../../../base/common/async.js'; +import { Emitter } from '../../../../../../../base/common/event.js'; import { toDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../../../base/common/observable.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; @@ -16,7 +18,7 @@ import { IWorkbenchLayoutService, Parts } from '../../../../../../services/layou import { IViewsService } from '../../../../../../services/views/common/viewsService.js'; import { ChatViewId } from '../../../../browser/chat.js'; import { ChatWidget } from '../../../../browser/widget/chatWidget.js'; -import { IChatViewHost } from '../../../../browser/chatViewHost.js'; +import { IChatViewHost, IChatViewHostShownSession } from '../../../../browser/chatViewHost.js'; import { ChatViewHostService } from '../../../../browser/widgetHosts/viewPane/chatViewHostService.js'; import { ChatViewPane } from '../../../../browser/widgetHosts/viewPane/chatViewPane.js'; @@ -30,7 +32,11 @@ suite('ChatViewHostService', () => { const widget = upcastPartial({}); const created = new DeferredPromise(); let accesses = 0; + const shownSession = observableValue('shownSession', undefined); + const viewVisibility = disposables.add(new Emitter<{ id: string; visible: boolean }>()); + let viewCreated = false; const view = upcastPartial({ + shownSession, widget: options.widgetCreatedLater ? undefined : widget, get whenWidget() { accesses++; return created.p; }, loadSession: async (resource: URI, reason?: string) => { calls.push(`loadSession ${resource.toString()} reason=${reason}`); return undefined; }, @@ -43,13 +49,16 @@ suite('ChatViewHostService', () => { instantiationService.stub(IViewsService, upcastPartial({ openView: (async (id: string, focus?: boolean) => { calls.push(`openView ${id} focus=${focus}`); return view; }) as IViewsService['openView'], isViewVisible: () => !!options.chatVisible, + onDidChangeViewVisibility: viewVisibility.event, + getViewWithId: (() => (viewCreated ? view : undefined) as T | null) as IViewsService['getViewWithId'], })); instantiationService.stub(IViewDescriptorService, upcastPartial({ getViewLocationById: () => ViewContainerLocation.AuxiliaryBar })); instantiationService.stub(IWorkbenchLayoutService, upcastPartial({ setPartHidden: (hidden, part) => { calls.push(`setPartHidden ${hidden} ${part}`); } })); instantiationService.stub(IProgressService, upcastPartial({ withProgress: (async (_options: unknown, task: () => Promise) => { calls.push('progress'); await task(); calls.push('progress done'); }) as IProgressService['withProgress'], })); - return { service: instantiationService.createInstance(ChatViewHostService), widget, view, created, calls, accesses: () => accesses }; + const showView = () => { viewCreated = true; viewVisibility.fire({ id: ChatViewId, visible: true }); }; + return { service: instantiationService.createInstance(ChatViewHostService), widget, view, created, calls, accesses: () => accesses, shownSession, showView }; } test('without a host that replaces it, the Chat view shows the chats as before', async () => { @@ -134,4 +143,33 @@ suite('ChatViewHostService', () => { created.complete(widget); assert.deepStrictEqual({ unchanged, waiting, revealed: await pending === widget }, { unchanged: undefined, waiting: { accesses: 1, settled: false }, revealed: true }); }); + + test('the shown chat is the one of the host in use: the Chat view\'s once it exists, a registered host\'s while it is registered, if it reports one', () => { + const { service, shownSession, showView } = createService(); + const shown = () => service.shownSession.get()?.resource.path ?? service.shownSession.get(); + const steps: Record = {}; + const chat = URI.parse('test:/chat'); + const other = URI.parse('test:/other'); + + shownSession.set({ resource: chat, reason: 'restore' }, undefined); + steps.beforeTheViewExists = shown(); + showView(); + steps.view = service.shownSession.get(); + const hostShown = observableValue('hostShown', { resource: other, reason: 'open' }); + const registration = service.registerHost({ ...upcastPartial({}), shownSession: hostShown }); + steps.registeredHost = shown(); + const silentRegistration = service.registerHost(upcastPartial({})); + steps.hostWithoutOne = shown(); + silentRegistration.dispose(); + registration.dispose(); + steps.backToTheView = shown(); + + assert.deepStrictEqual(steps, { + beforeTheViewExists: undefined, + view: { resource: chat, reason: 'restore' }, + registeredHost: '/other', + hostWithoutOne: undefined, + backToTheView: '/chat', + }); + }); }); From 56a16a82730f67ff7df2754bd500213a8a8f54bb Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 04:48:45 +0100 Subject: [PATCH 011/165] Workbench: a shown-session seam for chat view hosts, and shared folder picker sections (infra, unused) Infra for worktree support in the editor window; nothing registers or uses it yet. - IChatViewHostService.shownSession, with IChatViewHost.shownSession and IChatViewHostShownSession { resource, reason: 'open' | 'restore' }: the chat that the host in use shows, and whether the user brought it up or the host restored it. The Chat view reports it: 'restore' for the chat it restores, 'open' for a chat picked in its sessions list, a new chat, and a new chat that its first request made real. A host that replaces the Chat view reports its own; one that reports none leaves it undefined. - folderLocationSections.ts: the sections that every picker of a window's folders and their worktrees shows (Folder or Folders, then Worktrees, each with the current folder first), and their labels, so that the pickers share them. - AgentHostWorktreeLocationProvider is no longer registered here, and the setting moves out with its registration: the feature that uses them registers them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostWorktreeLocations.ts | 62 +------------------ .../agentSessions.contribution.ts | 1 - .../contrib/chat/browser/chatViewHost.ts | 16 +++++ .../viewPane/chatViewHostService.ts | 24 ++++++- .../widgetHosts/viewPane/chatViewPane.ts | 17 ++++- .../contrib/chat/common/constants.ts | 5 -- .../viewPane/chatViewHostService.test.ts | 42 ++++++++++++- .../common/folderLocationSections.ts | 52 ++++++++++++++++ .../common/folderLocationSections.test.ts | 39 ++++++++++++ 9 files changed, 185 insertions(+), 73 deletions(-) create mode 100644 src/vs/workbench/services/workspaces/common/folderLocationSections.ts create mode 100644 src/vs/workbench/services/workspaces/test/common/folderLocationSections.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts index b05f054c5a45a6..f9f07f83b22e96 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts @@ -5,25 +5,16 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { isAbsolute } from '../../../../../../base/common/path.js'; import { basename, extUriBiasedIgnorePathCase, joinPath } from '../../../../../../base/common/resources.js'; import { isDefined } from '../../../../../../base/common/types.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { localize } from '../../../../../../nls.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../../../platform/configuration/common/configurationRegistry.js'; import { FileOperationResult, IFileService, toFileOperationResult } from '../../../../../../platform/files/common/files.js'; -import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; -import { Registry } from '../../../../../../platform/registry/common/platform.js'; -import { IFolderLocation, IWorkspaceFolderLocationProvider, IWorkspaceFolderLocationService } from '../../../../../../platform/workspace/common/folderLocations.js'; -import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../common/contributions.js'; -import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; +import { IFolderLocation, IWorkspaceFolderLocationProvider } from '../../../../../../platform/workspace/common/folderLocations.js'; import { readFolderGitCheckout } from '../../../../../services/workspaces/common/gitFolderCheckout.js'; import { isLocalAgentHostTarget } from '../../../common/chatSessionsService.js'; -import { ChatConfiguration } from '../../../common/constants.js'; import { IAgentSession } from '../agentSessionsModel.js'; import { IAgentSessionsService } from '../agentSessionsService.js'; @@ -135,54 +126,3 @@ export class AgentHostWorktreeLocationProvider implements IWorkspaceFolderLocati return !!content && extUriBiasedIgnorePathCase.isEqual(isAbsolute(content) ? URI.file(content) : joinPath(worktreeGitDir, content), joinPath(folder, '.git')); } } - -/** Offers the worktrees of the agent host's sessions as locations of an editor window's folder, while the setting is on. */ -export class AgentHostWorktreeLocationsContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.agentHostWorktreeLocations'; - - private readonly _registration = this._register(new MutableDisposable()); - - constructor( - @IConfigurationService private readonly _configurationService: IConfigurationService, - @IWorkspaceFolderLocationService private readonly _folderLocationService: IWorkspaceFolderLocationService, - @IInstantiationService private readonly _instantiationService: IInstantiationService, - @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, - ) { - super(); - if (environmentService.isSessionsWindow) { - return; - } - this._update(); - this._register(_configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ChatConfiguration.EditorWindowEnableWorktree)) { - this._update(); - } - })); - } - - private _update(): void { - if (!this._configurationService.getValue(ChatConfiguration.EditorWindowEnableWorktree)) { - this._registration.clear(); - } else if (!this._registration.value) { - this._registration.value = this._folderLocationService.registerProvider(this._instantiationService.createInstance(AgentHostWorktreeLocationProvider), { builtIn: true }); - } - } -} - -Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ - id: 'chatSidebar', - title: localize('chatConfigurationTitle', "Chat"), - type: 'object', - properties: { - [ChatConfiguration.EditorWindowEnableWorktree]: { - type: 'boolean', - default: false, - tags: ['experimental'], - experiment: { mode: 'auto' }, - markdownDescription: localize('chat.editorWindow.enableWorktree', "Controls whether a window with a single local folder can move that folder to the worktrees its agent host sessions work in, and back to the folder's own location."), - }, - }, -}); - -registerWorkbenchContribution2(AgentHostWorktreeLocationsContribution.ID, AgentHostWorktreeLocationsContribution, WorkbenchPhase.BlockRestore); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts index 506c70e08cb0c8..9fd8154696b322 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts @@ -21,7 +21,6 @@ import { AgentHostPermissionUiContribution } from './agentHost/agentHostPermissi import { registerExternalSessionsFilterMenu } from './externalSessionsFilterMenu.js'; import './agentHost/agentHostChatInputPicker.contribution.js'; import './agentHost/agentHostModeSynchronizer.js'; -import './agentHost/agentHostWorktreeLocations.js'; import { ChatSessionArchiveActionWordingSettingId, getChatSessionArchiveActionWording } from '../../../../../platform/chat/common/sessionArchiveActions.js'; import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; diff --git a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts index fc6589af2b9484..226d8e995e7d8f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts +++ b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { IObservable } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { SessionTypeSelectionReason } from '../common/constants.js'; @@ -11,6 +12,16 @@ import { IChatWidget } from './chat.js'; export const IChatViewHostService = createDecorator('chatViewHostService'); +/** The chat that a host shows ({@link IChatViewHost.shownSession}), and why it shows it. */ +export interface IChatViewHostShownSession { + readonly resource: URI; + /** + * `open` when the user brought the chat up, such as by picking it in the sessions list, starting a new chat or sending + * the first request of one; `restore` when the host shows the chat it last showed, such as as the window starts. + */ + readonly reason: 'open' | 'restore'; +} + /** How {@link IChatViewHost.reveal} shows the host. */ export interface IChatViewHostRevealOptions { /** @@ -70,6 +81,8 @@ export interface IChatViewHost { toggle(): Promise; /** Starts showing the session of `sessionResource`, which its caller may send a first request to. */ startSession(sessionResource: URI, options: IChatViewHostSessionStartOptions): Promise; + /** The chat the host shows; a host that does not implement it reports none. */ + readonly shownSession?: IObservable; } /** @@ -81,6 +94,9 @@ export interface IChatViewHostService extends IChatViewHost { revealIfShowing(sessionResource: URI, options?: IChatViewHostOpenOptions): Promise; + /** The chat that the host in use shows; none while a host that does not report one ({@link IChatViewHost.shownSession}) is in use. */ + readonly shownSession: IObservable; + /** * Replaces the Chat view with `host` until the returned disposable is disposed. The host registered last is the one in * use; disposing it gives the use back to the one before. diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts index d433fe430427aa..b3358545b032f1 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts @@ -5,6 +5,7 @@ import { DeferredPromise, raceTimeout } from '../../../../../../base/common/async.js'; import { DisposableStore, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { derived, IObservable, ISettableObservable, observableFromEvent, observableValue } from '../../../../../../base/common/observable.js'; import { isEqual } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -13,7 +14,7 @@ import { IViewDescriptorService, ViewContainerLocation } from '../../../../../co import { IWorkbenchLayoutService, Parts } from '../../../../../services/layout/browser/layoutService.js'; import { IViewsService } from '../../../../../services/views/common/viewsService.js'; import { ChatViewId, IChatWidget } from '../../chat.js'; -import { IChatViewHost, IChatViewHostOpenOptions, IChatViewHostRevealOptions, IChatViewHostService, IChatViewHostSessionStart, IChatViewHostSessionStartOptions } from '../../chatViewHost.js'; +import { IChatViewHost, IChatViewHostOpenOptions, IChatViewHostRevealOptions, IChatViewHostService, IChatViewHostSessionStart, IChatViewHostSessionStartOptions, IChatViewHostShownSession } from '../../chatViewHost.js'; import { ChatViewPane } from './chatViewPane.js'; /** The Chat view, as the host of its chats. */ @@ -27,7 +28,13 @@ export class ChatViewPaneHost implements IChatViewHost { @IViewDescriptorService private readonly viewDescriptorService: IViewDescriptorService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IProgressService private readonly progressService: IProgressService, - ) { } + ) { + // The view exists once it first shows; it keeps its chat while hidden + const view = observableFromEvent(this, viewsService.onDidChangeViewVisibility, () => viewsService.getViewWithId(ChatViewId)); + this.shownSession = derived(this, reader => view.read(reader)?.shownSession.read(reader)); + } + + readonly shownSession: IObservable; async reveal(preserveFocus?: boolean, options?: IChatViewHostRevealOptions): Promise { const view = await this.viewsService.openView(ChatViewId, !preserveFocus); @@ -130,28 +137,39 @@ export class ChatViewHostService implements IChatViewHostService { private readonly chatViewHost: IChatViewHost; /** The registrations of the hosts that replace the Chat view, the one in use last: one each, also of the same host. */ private readonly registrations: { readonly host: IChatViewHost }[] = []; + private readonly hostInUse: ISettableObservable; + + readonly shownSession: IObservable; constructor( @IInstantiationService instantiationService: IInstantiationService, ) { this.chatViewHost = instantiationService.createInstance(ChatViewPaneHost); + this.hostInUse = observableValue(this, this.chatViewHost); + this.shownSession = derived(this, reader => this.hostInUse.read(reader).shownSession?.read(reader)); } private get host(): IChatViewHost { - return this.registrations.at(-1)?.host ?? this.chatViewHost; + return this.hostInUse.get(); } registerHost(host: IChatViewHost): IDisposable { const registration = { host }; this.registrations.push(registration); + this.updateHostInUse(); return toDisposable(() => { const index = this.registrations.indexOf(registration); if (index >= 0) { this.registrations.splice(index, 1); + this.updateHostInUse(); } }); } + private updateHostInUse(): void { + this.hostInUse.set(this.registrations.at(-1)?.host ?? this.chatViewHost, undefined); + } + reveal(preserveFocus?: boolean, options?: IChatViewHostRevealOptions): Promise { return this.host.reveal(preserveFocus, options); } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index ff50f904ca9847..43fa62ff15327f 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -71,6 +71,7 @@ import { IWorkbenchLayoutService, LayoutSettings, Position } from '../../../../. import { AgentSessionsViewerOrientation, AgentSessionsViewerPosition } from '../../agentSessions/agentSessions.js'; import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; import { CHAT_WIDGET_VIEW_STATE_CACHE_LIMIT, ChatViewId, IChatWidgetService, IChatWidgetViewState, setModelPreservingInputTypedWhileLoading } from '../../chat.js'; +import { IChatViewHostShownSession } from '../../chatViewHost.js'; import { IActivityService, ProgressBadge } from '../../../../../services/activity/common/activity.js'; import { DeferredPromise, disposableTimeout, raceCancellation, RunOnceScheduler } from '../../../../../../base/common/async.js'; import { AgentSessionsFilter, AgentSessionsGrouping } from '../../agentSessions/agentSessionsFilter.js'; @@ -169,6 +170,11 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private readonly activityBadge = this._register(new MutableDisposable()); private readonly _currentSessionResource = observableValue(this, undefined); + /** Whether the view restores the chat it last showed, rather than showing one that the user brought up. */ + private _restoringModel = false; + private readonly _shownSession = observableValue(this, undefined); + /** The chat the view shows, and whether it shows it as the user brought it up or as it restored it. */ + readonly shownSession: IObservable = this._shownSession; private readonly customizationMigrationsAvailable = observableValue(this, false); /** * Session resource of the last-focused chat widget, or this pane's own @@ -378,8 +384,10 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { try { this._widget.setVisible(false); + this._restoringModel = true; await this.showModel(cts.token, session.modelRef, true, !session.modelRef, undefined, session.localFallbackSelectionReason); } finally { + this._restoringModel = false; this._widget.setVisible(wasVisible); } }).catch(error => { @@ -1246,6 +1254,8 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { const model = chatWidget.viewModel?.model; this.titleControl?.update(model); this._currentSessionResource.set(chatWidget.viewModel?.sessionResource, undefined); + const shownResource = chatWidget.viewModel?.sessionResource; + this._shownSession.set(shownResource && { resource: shownResource, reason: this._restoringModel ? 'restore' : 'open' }, undefined); if (this.sessionsViewerOrientation === AgentSessionsViewerOrientation.Stacked) { return; // only reveal in side-by-side mode @@ -1385,7 +1395,12 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private async _applyModel(token: CancellationToken): Promise { const session = await this.acquireTransferredOrPersistedSession(token, 'ChatViewPane#applyModel'); - await this.showModel(token, session.modelRef, true, !session.modelRef, undefined, session.localFallbackSelectionReason); + this._restoringModel = true; + try { + await this.showModel(token, session.modelRef, true, !session.modelRef, undefined, session.localFallbackSelectionReason); + } finally { + this._restoringModel = false; + } } /** diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 134e29231a4374..8ef96a99bf1bc3 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -52,11 +52,6 @@ export enum ChatConfiguration { AgentsPickerInAttachContextMenu = 'sessions.chat.experimental.agentsPickerInAttachContextMenu', /** Desktop only: lets chat attach a screenshot of the window of another app. */ AppWindowScreenshotsEnabled = 'chat.appWindowScreenshots.enabled', - /** - * Lets an editor window with one local folder move that folder between its primary location and the worktrees its - * agent host sessions work in, and offer them when starting a session. - */ - EditorWindowEnableWorktree = 'chat.editorWindow.enableWorktree', PlanAgentDefaultModel = 'chat.planAgent.defaultModel', ExploreAgentDefaultModel = 'chat.exploreAgent.defaultModel', UtilityModel = 'chat.utilityModel', diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts index 25db610ef256bf..4408658d2fb144 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts @@ -5,7 +5,9 @@ import assert from 'assert'; import { DeferredPromise, timeout } from '../../../../../../../base/common/async.js'; +import { Emitter } from '../../../../../../../base/common/event.js'; import { toDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../../../base/common/observable.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; @@ -16,7 +18,7 @@ import { IWorkbenchLayoutService, Parts } from '../../../../../../services/layou import { IViewsService } from '../../../../../../services/views/common/viewsService.js'; import { ChatViewId } from '../../../../browser/chat.js'; import { ChatWidget } from '../../../../browser/widget/chatWidget.js'; -import { IChatViewHost } from '../../../../browser/chatViewHost.js'; +import { IChatViewHost, IChatViewHostShownSession } from '../../../../browser/chatViewHost.js'; import { ChatViewHostService } from '../../../../browser/widgetHosts/viewPane/chatViewHostService.js'; import { ChatViewPane } from '../../../../browser/widgetHosts/viewPane/chatViewPane.js'; @@ -30,7 +32,11 @@ suite('ChatViewHostService', () => { const widget = upcastPartial({}); const created = new DeferredPromise(); let accesses = 0; + const shownSession = observableValue('shownSession', undefined); + const viewVisibility = disposables.add(new Emitter<{ id: string; visible: boolean }>()); + let viewCreated = false; const view = upcastPartial({ + shownSession, widget: options.widgetCreatedLater ? undefined : widget, get whenWidget() { accesses++; return created.p; }, loadSession: async (resource: URI, reason?: string) => { calls.push(`loadSession ${resource.toString()} reason=${reason}`); return undefined; }, @@ -43,13 +49,16 @@ suite('ChatViewHostService', () => { instantiationService.stub(IViewsService, upcastPartial({ openView: (async (id: string, focus?: boolean) => { calls.push(`openView ${id} focus=${focus}`); return view; }) as IViewsService['openView'], isViewVisible: () => !!options.chatVisible, + onDidChangeViewVisibility: viewVisibility.event, + getViewWithId: (() => (viewCreated ? view : undefined) as T | null) as IViewsService['getViewWithId'], })); instantiationService.stub(IViewDescriptorService, upcastPartial({ getViewLocationById: () => ViewContainerLocation.AuxiliaryBar })); instantiationService.stub(IWorkbenchLayoutService, upcastPartial({ setPartHidden: (hidden, part) => { calls.push(`setPartHidden ${hidden} ${part}`); } })); instantiationService.stub(IProgressService, upcastPartial({ withProgress: (async (_options: unknown, task: () => Promise) => { calls.push('progress'); await task(); calls.push('progress done'); }) as IProgressService['withProgress'], })); - return { service: instantiationService.createInstance(ChatViewHostService), widget, view, created, calls, accesses: () => accesses }; + const showView = () => { viewCreated = true; viewVisibility.fire({ id: ChatViewId, visible: true }); }; + return { service: instantiationService.createInstance(ChatViewHostService), widget, view, created, calls, accesses: () => accesses, shownSession, showView }; } test('without a host that replaces it, the Chat view shows the chats as before', async () => { @@ -134,4 +143,33 @@ suite('ChatViewHostService', () => { created.complete(widget); assert.deepStrictEqual({ unchanged, waiting, revealed: await pending === widget }, { unchanged: undefined, waiting: { accesses: 1, settled: false }, revealed: true }); }); + + test('the shown chat is the one of the host in use: the Chat view\'s once it exists, a registered host\'s while it is registered, if it reports one', () => { + const { service, shownSession, showView } = createService(); + const shown = () => service.shownSession.get()?.resource.path ?? service.shownSession.get(); + const steps: Record = {}; + const chat = URI.parse('test:/chat'); + const other = URI.parse('test:/other'); + + shownSession.set({ resource: chat, reason: 'restore' }, undefined); + steps.beforeTheViewExists = shown(); + showView(); + steps.view = service.shownSession.get(); + const hostShown = observableValue('hostShown', { resource: other, reason: 'open' }); + const registration = service.registerHost({ ...upcastPartial({}), shownSession: hostShown }); + steps.registeredHost = shown(); + const silentRegistration = service.registerHost(upcastPartial({})); + steps.hostWithoutOne = shown(); + silentRegistration.dispose(); + registration.dispose(); + steps.backToTheView = shown(); + + assert.deepStrictEqual(steps, { + beforeTheViewExists: undefined, + view: { resource: chat, reason: 'restore' }, + registeredHost: '/other', + hostWithoutOne: undefined, + backToTheView: '/chat', + }); + }); }); diff --git a/src/vs/workbench/services/workspaces/common/folderLocationSections.ts b/src/vs/workbench/services/workspaces/common/folderLocationSections.ts new file mode 100644 index 00000000000000..80b0c34923304c --- /dev/null +++ b/src/vs/workbench/services/workspaces/common/folderLocationSections.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../nls.js'; + +/** What a folder that a picker offers is: a folder of the window, such as its primary checkout, or a worktree of it. */ +export type FolderLocationKind = 'folder' | 'worktree'; + +/** A folder that a picker offers, with what it is and whether the window's folder is there now. */ +export interface IFolderLocationPickerEntry { + readonly item: T; + readonly kind: FolderLocationKind; + readonly current: boolean; +} + +/** A section of a folder picker: its title and its items, in the order they show. */ +export interface IFolderLocationPickerSection { + readonly kind: FolderLocationKind; + readonly title: string; + readonly items: readonly T[]; +} + +/** + * The sections of a picker that offers a window's folders and their worktrees, as every such picker shows them: after New + * Worktree, which comes first, a Folder section (Folders with several) and a Worktrees section. Each lists the folder that + * the window's folder is at now first, which shows {@link getCurrentFolderLocationDescription} as its description, and + * then the others in their order. A section without items is left out. + */ +export function groupFolderLocations(entries: readonly IFolderLocationPickerEntry[]): IFolderLocationPickerSection[] { + const sections: IFolderLocationPickerSection[] = []; + const section = (kind: FolderLocationKind, title: (count: number) => string) => { + const ofKind = entries.filter(entry => entry.kind === kind); + if (ofKind.length) { + sections.push({ kind, title: title(ofKind.length), items: [...ofKind.filter(entry => entry.current), ...ofKind.filter(entry => !entry.current)].map(entry => entry.item) }); + } + }; + section('folder', count => count > 1 ? localize('folderLocations.folders', "Folders") : localize('folderLocations.folder', "Folder")); + section('worktree', () => localize('folderLocations.worktrees', "Worktrees")); + return sections; +} + +/** The description of the folder that the window's folder is at now, in a picker of {@link groupFolderLocations}. */ +export function getCurrentFolderLocationDescription(): string { + return localize('folderLocations.current', "Current"); +} + +/** The label of the choice, first in a picker of {@link groupFolderLocations}, that starts a session in a new worktree. */ +export function getNewWorktreeLabel(): string { + return localize('folderLocations.newWorktree', "New Worktree"); +} diff --git a/src/vs/workbench/services/workspaces/test/common/folderLocationSections.test.ts b/src/vs/workbench/services/workspaces/test/common/folderLocationSections.test.ts new file mode 100644 index 00000000000000..06587ca46e99b6 --- /dev/null +++ b/src/vs/workbench/services/workspaces/test/common/folderLocationSections.test.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { groupFolderLocations } from '../../common/folderLocationSections.js'; + +suite('FolderLocationSections', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('a Folder section and a Worktrees section, each with the current folder first and the others in their order, without empty sections', () => { + const sections = (entries: Parameters>[0]) => groupFolderLocations(entries).map(section => `${section.title}: ${section.items.join(', ')}`); + + assert.deepStrictEqual({ + atWorktree: sections([ + { item: 'a', kind: 'worktree', current: false }, + { item: 'vscode', kind: 'folder', current: false }, + { item: 'b', kind: 'worktree', current: true }, + { item: 'c', kind: 'worktree', current: false }, + ]), + atFolder: sections([ + { item: 'a', kind: 'worktree', current: false }, + { item: 'vscode', kind: 'folder', current: true }, + ]), + severalFolders: sections([ + { item: 'vscode', kind: 'folder', current: false }, + { item: 'tools', kind: 'folder', current: true }, + ]), + none: sections([]), + }, { + atWorktree: ['Folder: vscode', 'Worktrees: b, a, c'], + atFolder: ['Folder: vscode', 'Worktrees: a'], + severalFolders: ['Folders: tools, vscode'], + none: [], + }); + }); +}); From e461546db3b58ad817d03f9b783d2da2716100b4 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 04:53:08 +0100 Subject: [PATCH 012/165] Editor window: worktree support behind chat.editorWindow.enableWorktree A window with a single local folder follows the chats that work in the worktrees of that folder, whichever surface shows them (IChatViewHostService.shownSession), behind the experimental, experiment-controlled setting chat.editorWindow.enableWorktree (off by default). - The worktrees that the local agent host's sessions work in are locations of the folder (AgentHostWorktreeLocationProvider, registered while the setting and AI features are on). - A chat that the user opens moves the folder to where it works, and a local chat back to the folder's own location, superseding any switch under way. - The shown session moves the folder on its own once its working directory is known, such as after its first request, and as it changes, such as to a worktree its agent created. Such a switch gives way to any other, and applies only while the user opened no chat since it started. - It remembers the last chat opened at each location and opens it as the user moves the folder there themselves. - Terminals that started where the folder no longer is are marked. - As the setting or AI features are turned off, the folder returns from a worktree to its own location. - It logs the setting's experiment trigger where the folder would move, with the setting on or off, and how each switch ended. This replaces the earlier follow contribution of this branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostWorktree.contribution.ts | 427 ++++++++++++++++++ .../agentHost/agentHostWorktreeFollow.ts | 111 ----- .../agentSessions.contribution.ts | 2 +- .../contrib/chat/common/constants.ts | 5 + .../agentHost/agentHostWorktree.test.ts | 197 ++++++++ .../agentHost/agentHostWorktreeFollow.test.ts | 122 ----- 6 files changed, 630 insertions(+), 234 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts delete mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFollow.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts delete mode 100644 src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFollow.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts new file mode 100644 index 00000000000000..3f4870b351f13e --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts @@ -0,0 +1,427 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { toErrorMessage } from '../../../../../../base/common/errorMessage.js'; +import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../../../base/common/map.js'; +import { Schemas } from '../../../../../../base/common/network.js'; +import { autorun } from '../../../../../../base/common/observable.js'; +import { extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; +import Severity from '../../../../../../base/common/severity.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { localize } from '../../../../../../nls.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../../../platform/configuration/common/configurationRegistry.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { FileOperationResult, IFileService, toFileOperationResult } from '../../../../../../platform/files/common/files.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; +import { Registry } from '../../../../../../platform/registry/common/platform.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; +import { logSettingExperimentTrigger } from '../../../../../../platform/telemetry/common/experimentTrigger.js'; +import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { IWorkspaceFolderLocationService } from '../../../../../../platform/workspace/common/folderLocations.js'; +import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../common/contributions.js'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; +import { IWorkspaceEditingService } from '../../../../../services/workspaces/common/workspaceEditing.js'; +import { ITerminalInstance, ITerminalService } from '../../../../terminal/browser/terminal.js'; +import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; +import { isLocalAgentHostTarget } from '../../../common/chatSessionsService.js'; +import { ChatConfiguration } from '../../../common/constants.js'; +import { isUntitledChatSession, LocalChatSessionUri } from '../../../common/model/chatUri.js'; +import { IChatViewHostService } from '../../chatViewHost.js'; +import { IAgentSessionsService } from '../agentSessionsService.js'; +import { AGENT_HOST_WORKTREE_LOCATIONS_PROVIDER_ID, AgentHostWorktreeLocationProvider } from './agentHostWorktreeLocations.js'; + +type WorktreeFolderSwitchDirection = 'toAlternate' | 'toPrimary'; +type WorktreeFolderSwitchOutcome = 'switched' | 'missing' | 'failed' | 'superseded'; + +type WorktreeFolderSwitchEvent = { + direction: WorktreeFolderSwitchDirection; + outcome: WorktreeFolderSwitchOutcome; + automatic: boolean; +}; + +type WorktreeFolderSwitchClassification = { + direction: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the folder moved to a worktree of the window\'s folder or back to its own location.' }; + outcome: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the switch happened, its folder no longer exists, it failed, or a later switch replaced it.' }; + automatic: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the switch followed a chat on its own, such as into the worktree its agent created, rather than a chat the user opened.' }; + owner: 'sandy081'; + comment: 'Tracks how the folder of an editor window follows the chats that work in its worktrees.'; +}; + +const FOLDER_LOCATION_TERMINAL_STATUS_ID = 'editorWindow.worktree.folderLocation'; +const CHAT_ENABLED_KEYS = new Set([ChatContextKeys.enabled.key]); +/** The last chat the user opened at each location of the window's folder, in the storage of its workspace, which is the primary's. */ +const LAST_SESSIONS_STORAGE_KEY = 'editorWindow.worktree.lastSessions'; +const LAST_SESSIONS_LIMIT = 20; + +/** + * Worktree support in an editor window with a single local folder, while `chat.editorWindow.enableWorktree` and AI features + * are on: the worktrees that the local agent host's sessions work in are locations of the folder, and the folder follows + * the chat that the host in use shows ({@link IChatViewHostService.shownSession}), whichever surface that is. + * + * - A chat the user opens moves the folder to where it works: the working directory of a local agent host session, or the + * folder's own location for a local chat. It supersedes any switch under way. + * - A shown session that comes to work elsewhere, such as in the worktree its agent created, or whose working directory + * becomes known only after its first request, moves the folder on its own: such an automatic switch gives way to any + * other switch, and applies only while the user asked for none since it started. + * - It remembers the last chat opened at each location, and opens it as the user moves the folder there themselves. + * - Terminals that started where the folder no longer is are marked. + * - As the setting or AI features are turned off, the folder returns from a worktree to its own location. + */ +export class AgentHostWorktreeContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.agentHostWorktree'; + + private readonly _provider: AgentHostWorktreeLocationProvider; + private readonly _providerRegistration = this._register(new MutableDisposable()); + private _enabled = false; + + private _request = 0; + /** Changes as the folder moves or the window's folders change, and as the user opens a chat: an automatic switch that started before does not apply. */ + private _foreground = 0; + /** The switches of its own under way, from their request to the workspace's answer. */ + private _switching = 0; + /** The moves it asked the workspace for that have not completed yet. */ + private _pendingMoves = 0; + /** Where it last moved the folder: that move is its own, not the user's. */ + private _ownMove: URI | undefined; + + /** The chat shown, and the working directory it was last known to work in. */ + private _shown: { readonly resource: URI; workingDirectory: URI | undefined } | undefined; + /** Waits for the shown session's working directory to become known or to change. */ + private readonly _sessionsListener = this._register(new MutableDisposable()); + + private readonly _lastSessions: ResourceMap; + /** The locations the folder has been at in this window, whose terminals started there are marked once it moves. */ + private readonly _visitedLocations: URI[] = []; + + constructor( + @IChatViewHostService private readonly _chatViewHostService: IChatViewHostService, + @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IWorkspaceFolderLocationService private readonly _folderLocationService: IWorkspaceFolderLocationService, + @IWorkspaceEditingService private readonly _workspaceEditingService: IWorkspaceEditingService, + @IFileService private readonly _fileService: IFileService, + @ILabelService private readonly _labelService: ILabelService, + @INotificationService private readonly _notificationService: INotificationService, + @ITerminalService private readonly _terminalService: ITerminalService, + @IStorageService private readonly _storageService: IStorageService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + @ILogService private readonly _logService: ILogService, + @IInstantiationService instantiationService: IInstantiationService, + @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, + ) { + super(); + this._provider = instantiationService.createInstance(AgentHostWorktreeLocationProvider); + this._lastSessions = this._readLastSessions(); + if (environmentService.isSessionsWindow) { + return; + } + + this._updateEnablement(); + this._register(_configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ChatConfiguration.EditorWindowEnableWorktree)) { + this._updateEnablement(); + } + })); + this._register(_contextKeyService.onDidChangeContext(e => { + if (e.affectsSome(CHAT_ENABLED_KEYS)) { + this._updateEnablement(); + } + })); + + this._register(autorun(reader => { + const shown = _chatViewHostService.shownSession.read(reader); + this._sessionsListener.clear(); + this._shown = shown && !isUntitledChatSession(shown.resource) ? { resource: shown.resource, workingDirectory: undefined } : undefined; + if (this._shown && shown?.reason === 'open') { + this._foreground++; + this._follow(this._shown, false); + } else if (this._shown) { + // A restored chat moves nothing, but the folder follows it as it comes to work elsewhere + this._shown.workingDirectory = this._workingDirectory(this._shown.resource); + this._watchWorkingDirectory(this._shown); + } + })); + + this._register(_workspaceContextService.onDidChangeWorkspaceFolders(() => { + this._foreground++; + this._markTerminals(); + })); + this._register(_workspaceContextService.onDidChangeWorkbenchState(() => this._foreground++)); + this._register(_folderLocationService.onDidChangeLocation(e => { + this._foreground++; + const own = this._ownMove && extUriBiasedIgnorePathCase.isEqual(this._ownMove, e.folder.uri); + this._ownMove = undefined; + // The user moved the folder themselves, such as in the window's title: the last chat opened there shows + if (!own && e.reason === 'user' && this._enabled) { + this._openLastSession(e.folder); + } + })); + this._register(_terminalService.onDidCreateInstance(instance => this._markTerminal(instance))); + this._markTerminals(); + } + + /** The session the user last opened while the folder was at `location`, its own location or a worktree. */ + getLastSession(location: URI): URI | undefined { + return this._lastSessions.get(location); + } + + private _isEnabledBySettings(): boolean { + return !!this._configurationService.getValue(ChatConfiguration.EditorWindowEnableWorktree) + && !!this._contextKeyService.getContextKeyValue(ChatContextKeys.enabled.key); + } + + /** + * Offers the worktrees while enabled. As it turns off, a move it asked for that is still under way does not apply, and the + * folder returns from a worktree to its own location. + */ + private _updateEnablement(): void { + const enabled = this._isEnabledBySettings(); + if (enabled === this._enabled) { + return; + } + this._enabled = enabled; + if (enabled) { + this._providerRegistration.value = this._folderLocationService.registerProvider(this._provider, { builtIn: true }); + return; + } + this._providerRegistration.clear(); + this._request++; + const workspace = this._workspaceContextService.getWorkspace(); + const folder = workspace.folders[0]; + const atWorktree = !!folder && this._folderLocationService.getLocation(folder)?.providerId === AGENT_HOST_WORKTREE_LOCATIONS_PROVIDER_ID; + if (!folder || !workspace.uri || !this._folderLocationService.canSetLocation(folder) || (!atWorktree && this._pendingMoves === 0)) { + return; + } + // Back to its own location from a worktree; else where the folder is, which supersedes a move under way + const target = atWorktree ? workspace.uri : folder.uri; + this._workspaceEditingService.setFolderLocation(target).then( + () => { if (atWorktree) { this._log('toPrimary', 'switched', true); } }, + error => { this._logService.error('[worktree] could not return the folder to its own location', error); if (atWorktree) { this._log('toPrimary', 'failed', true); } }); + } + + /** The working directory of the local agent host session of `resource`, as the sessions list has it. */ + private _workingDirectory(resource: URI): URI | undefined { + if (!isLocalAgentHostTarget(resource.scheme)) { + return undefined; + } + const path = this._agentSessionsService.getSession(resource)?.metadata?.workingDirectoryPath; + return typeof path === 'string' && path ? URI.file(path) : undefined; + } + + /** Follows the shown agent host session on its own as its working directory becomes known or changes. */ + private _watchWorkingDirectory(shown: { readonly resource: URI; workingDirectory: URI | undefined }): void { + if (!isLocalAgentHostTarget(shown.resource.scheme)) { + return; + } + this._sessionsListener.value = this._agentSessionsService.model.onDidChangeSessions(() => { + const workingDirectory = this._workingDirectory(shown.resource); + if (this._shown === shown && workingDirectory && !extUriBiasedIgnorePathCase.isEqual(workingDirectory, shown.workingDirectory)) { + shown.workingDirectory = workingDirectory; + this._follow(shown, true); + } + }); + } + + /** Moves the folder to where the shown chat works: on the user's behalf, or on its own (`automatic`). */ + private _follow(shown: { readonly resource: URI; workingDirectory: URI | undefined }, automatic: boolean): void { + const folder = this._workspaceContextService.getWorkspace().folders[0]; + const primary = this._workspaceContextService.getWorkspace().uri; + if (!folder || !primary || !this._folderLocationService.canSetLocation(folder)) { + return; + } + let target: URI | undefined; + if (LocalChatSessionUri.isLocalSession(shown.resource)) { + target = primary; + } else if (isLocalAgentHostTarget(shown.resource.scheme)) { + shown.workingDirectory = this._workingDirectory(shown.resource); + // Not listed with its working directory yet, such as a session its first request just started: it follows as it is + this._watchWorkingDirectory(shown); + target = shown.workingDirectory; + } + if (!target || target.scheme !== Schemas.file || extUriBiasedIgnorePathCase.isEqualOrParent(target, folder.uri)) { + return; + } + // The folder would move here: the same moment with the setting on and off + logSettingExperimentTrigger(this._telemetryService, ChatConfiguration.EditorWindowEnableWorktree); + if (!this._enabled || (automatic && this._switching > 0)) { + return; + } + void this._switch(shown.resource, primary, target, automatic ? this._foreground : undefined); + } + + /** @param foreground of an automatic switch, the {@link _foreground} it started at. */ + private async _switch(session: URI, primary: URI, target: URI, foreground: number | undefined): Promise { + const automatic = foreground !== undefined; + const request = ++this._request; + const direction: WorktreeFolderSwitchDirection = extUriBiasedIgnorePathCase.isEqual(target, primary) ? 'toPrimary' : 'toAlternate'; + this._switching++; + try { + if (direction === 'toAlternate') { + if (!await this._exists(target)) { + if (request === this._request && !automatic) { + this._notificationService.info(localize('worktree.missing', "The folder {0} of this chat no longer exists.", this._labelService.getUriLabel(target, { noPrefix: true }))); + } + this._log(direction, 'missing', automatic); + return; + } + if (!await this._folderLocationService.resolveLocation(primary, target, CancellationToken.None)) { + // A working directory that is no worktree of the folder: the chat opens in place + return; + } + } + // Read again right before the move: it may have been turned off, or a later request came, meanwhile + if (request !== this._request || !this._enabled || (automatic && foreground !== this._foreground)) { + this._log(direction, 'superseded', automatic); + return; + } + if (!automatic) { + this._rememberSession(target, session); + } + this._pendingMoves++; + this._ownMove = target; + try { + // An automatic move gives way to a switch already under way in the workspace, such as one in the window's title + await this._workspaceEditingService.setFolderLocation(target, automatic ? { ifIdle: true, reason: 'programmatic' } : { reason: 'user' }); + } finally { + this._pendingMoves--; + } + const applied = request === this._request && this._isAt(target); + if (automatic && applied) { + this._rememberSession(target, session); + } + this._log(direction, applied ? 'switched' : 'superseded', automatic); + } catch (error) { + this._logService.error('[worktree] could not move the folder to where a chat works', error); + this._log(direction, 'failed', automatic); + if (!automatic && request === this._request) { + this._notificationService.error(localize('worktree.error', "Could not move the folder to where the chat works: {0}", toErrorMessage(error))); + } + } finally { + this._switching--; + } + } + + private _isAt(location: URI): boolean { + const folder = this._workspaceContextService.getWorkspace().folders[0]; + return !!folder && extUriBiasedIgnorePathCase.isEqual(folder.uri, location); + } + + private async _exists(resource: URI): Promise { + try { + await this._fileService.stat(resource); + return true; + } catch (error) { + if (error instanceof Error && toFileOperationResult(error) === FileOperationResult.FILE_NOT_FOUND) { + return false; + } + throw error; + } + } + + private _openLastSession(folder: IWorkspaceFolder): void { + const last = this.getLastSession(folder.uri); + if (last && !extUriBiasedIgnorePathCase.isEqual(last, this._shown?.resource)) { + this._chatViewHostService.openSession(last, { preserveFocus: true }).catch(error => this._logService.trace('[worktree] could not open the last chat of the folder\'s location', error)); + } + } + + private _markTerminals(): void { + const folder = this._workspaceContextService.getWorkspace().folders[0]; + if (folder && !this._visitedLocations.some(location => extUriBiasedIgnorePathCase.isEqual(location, folder.uri))) { + this._visitedLocations.push(folder.uri); + } + for (const instance of this._terminalService.instances) { + this._markTerminal(instance); + } + } + + /** + * Marks a running terminal that started in a location the folder has been at in this window but is no longer at: it + * keeps working there, while new terminals start where the folder is now. + */ + private _markTerminal(instance: ITerminalInstance): void { + const folder = this._workspaceContextService.getWorkspace().folders[0]; + const cwd = instance.initialCwd ? URI.file(instance.initialCwd) : undefined; + const startedAt = cwd && this._visitedLocations.find(location => extUriBiasedIgnorePathCase.isEqualOrParent(cwd, location)); + if (folder && cwd && startedAt && !extUriBiasedIgnorePathCase.isEqualOrParent(cwd, folder.uri)) { + instance.statusList.add({ + id: FOLDER_LOCATION_TERMINAL_STATUS_ID, + severity: Severity.Info, + icon: Codicon.folder, + tooltip: localize('worktree.terminal', "Started in {0}, not where the folder is now.", this._labelService.getUriLabel(startedAt, { noPrefix: true })), + }); + } else { + instance.statusList.remove(FOLDER_LOCATION_TERMINAL_STATUS_ID); + } + } + + private _rememberSession(location: URI, session: URI): void { + // A chat is the last one of the location it works at only, and the most recent is last, so the oldest goes first + for (const [known, resource] of [...this._lastSessions]) { + if (extUriBiasedIgnorePathCase.isEqual(resource, session)) { + this._lastSessions.delete(known); + } + } + this._lastSessions.delete(location); + this._lastSessions.set(location, session); + for (const [oldest] of this._lastSessions) { + if (this._lastSessions.size <= LAST_SESSIONS_LIMIT) { + break; + } + this._lastSessions.delete(oldest); + } + const entries = [...this._lastSessions].map(([known, resource]) => [known.toString(), resource.toString()]); + this._storageService.store(LAST_SESSIONS_STORAGE_KEY, JSON.stringify(entries), StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + + private _readLastSessions(): ResourceMap { + const sessions = new ResourceMap(uri => extUriBiasedIgnorePathCase.getComparisonKey(uri)); + try { + const stored: unknown = JSON.parse(this._storageService.get(LAST_SESSIONS_STORAGE_KEY, StorageScope.WORKSPACE, '[]')); + if (Array.isArray(stored)) { + for (const entry of stored.slice(-LAST_SESSIONS_LIMIT)) { + if (Array.isArray(entry) && typeof entry[0] === 'string' && typeof entry[1] === 'string') { + sessions.set(URI.parse(entry[0]), URI.parse(entry[1])); + } + } + } + } catch { + // What it stored cannot be read: it starts without + } + return sessions; + } + + private _log(direction: WorktreeFolderSwitchDirection, outcome: WorktreeFolderSwitchOutcome, automatic: boolean): void { + this._telemetryService.publicLog2('editorWindow.worktreeFolderSwitch', { direction, outcome, automatic }); + } +} + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + id: 'chatSidebar', + title: localize('chatConfigurationTitle', "Chat"), + type: 'object', + properties: { + [ChatConfiguration.EditorWindowEnableWorktree]: { + type: 'boolean', + default: false, + tags: ['experimental'], + experiment: { mode: 'auto' }, + markdownDescription: localize('chat.editorWindow.enableWorktree', "Controls whether a window with a single local folder moves that folder to the worktree that the chat you open works in, such as a worktree its agent created, and back to the folder's own location for a local chat."), + }, + }, +}); + +registerWorkbenchContribution2(AgentHostWorktreeContribution.ID, AgentHostWorktreeContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFollow.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFollow.ts deleted file mode 100644 index d5752bb25a7e23..00000000000000 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFollow.ts +++ /dev/null @@ -1,111 +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 { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; -import { autorun } from '../../../../../../base/common/observable.js'; -import { extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; -import { URI } from '../../../../../../base/common/uri.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { ILogService } from '../../../../../../platform/log/common/log.js'; -import { logSettingExperimentTrigger } from '../../../../../../platform/telemetry/common/experimentTrigger.js'; -import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; -import { IWorkspaceFolderLocationService } from '../../../../../../platform/workspace/common/folderLocations.js'; -import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; -import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../common/contributions.js'; -import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; -import { IWorkspaceEditingService } from '../../../../../services/workspaces/common/workspaceEditing.js'; -import { isLocalAgentHostTarget } from '../../../common/chatSessionsService.js'; -import { ChatConfiguration } from '../../../common/constants.js'; -import { isUntitledChatSession, LocalChatSessionUri } from '../../../common/model/chatUri.js'; -import { IChatViewHostService } from '../../chatViewHost.js'; -import { IAgentSessionsService } from '../agentSessionsService.js'; - -/** - * Moves the folder of a window with a single local folder to where the chat that the user brings up works, while - * `chat.editorWindow.enableWorktree` is on: to the working directory of a local agent host session, such as a worktree, and - * back to the folder's own location for a local chat. A chat that the view restores, such as as the window starts, moves - * nothing; nor does a chat of another kind, such as a cloud session. - */ -export class AgentHostWorktreeFollowContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.agentHostWorktreeFollow'; - - /** The agent host session that the user brought up, until its working directory is known. */ - private _pending: URI | undefined; - /** Waits for the working directory of {@link _pending} as the sessions update. */ - private readonly _sessionsListener = this._register(new MutableDisposable()); - - constructor( - @IChatViewHostService chatViewHostService: IChatViewHostService, - @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, - @IConfigurationService private readonly _configurationService: IConfigurationService, - @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, - @IWorkspaceFolderLocationService private readonly _folderLocationService: IWorkspaceFolderLocationService, - @IWorkspaceEditingService private readonly _workspaceEditingService: IWorkspaceEditingService, - @ITelemetryService private readonly _telemetryService: ITelemetryService, - @ILogService private readonly _logService: ILogService, - @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, - ) { - super(); - if (environmentService.isSessionsWindow) { - return; - } - this._register(autorun(reader => { - const shown = chatViewHostService.shownSession.read(reader); - this._pending = undefined; - this._sessionsListener.clear(); - if (shown?.reason === 'open') { - this._follow(shown.resource); - } - })); - } - - /** Moves the window's folder to where the chat of `resource` works, once that is known. */ - private _follow(resource: URI): void { - const folder = this._workspaceContextService.getWorkspace().folders[0]; - const primary = this._workspaceContextService.getWorkspace().uri; - if (!folder || !primary || !this._folderLocationService.canSetLocation(folder) || isUntitledChatSession(resource)) { - return; - } - - let target: URI; - if (LocalChatSessionUri.isLocalSession(resource)) { - target = primary; - } else if (isLocalAgentHostTarget(resource.scheme)) { - const path = this._agentSessionsService.getSession(resource)?.metadata?.workingDirectoryPath; - if (typeof path !== 'string' || !path) { - // Not listed with its working directory yet, such as a session that its first request just started - this._pending = resource; - this._sessionsListener.value ??= this._agentSessionsService.model.onDidChangeSessions(() => { - if (this._pending) { - const pending = this._pending; - this._pending = undefined; - this._sessionsListener.clear(); - this._follow(pending); - } - }); - return; - } - target = URI.file(path); - } else { - return; - } - - if (extUriBiasedIgnorePathCase.isEqual(target, folder.uri)) { - return; - } - // The folder would move here: the same moment with the setting on and off - logSettingExperimentTrigger(this._telemetryService, ChatConfiguration.EditorWindowEnableWorktree); - if (!this._configurationService.getValue(ChatConfiguration.EditorWindowEnableWorktree)) { - return; - } - this._workspaceEditingService.setFolderLocation(target, { reason: 'user', folder: folder.uri }).catch(error => { - // Such as a working directory that is not a location of the folder - this._logService.trace(`[AgentHostWorktreeFollow] Did not move the folder to ${target.toString()}`, error); - }); - } -} - -registerWorkbenchContribution2(AgentHostWorktreeFollowContribution.ID, AgentHostWorktreeFollowContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts index 88c670d5b4e7bd..9ad4c94300af97 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts @@ -21,7 +21,7 @@ import { AgentHostPermissionUiContribution } from './agentHost/agentHostPermissi import { registerExternalSessionsFilterMenu } from './externalSessionsFilterMenu.js'; import './agentHost/agentHostChatInputPicker.contribution.js'; import './agentHost/agentHostModeSynchronizer.js'; -import './agentHost/agentHostWorktreeFollow.js'; +import './agentHost/agentHostWorktree.contribution.js'; import { ChatSessionArchiveActionWordingSettingId, getChatSessionArchiveActionWording } from '../../../../../platform/chat/common/sessionArchiveActions.js'; import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 8ef96a99bf1bc3..038d32817d44e9 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -52,6 +52,11 @@ export enum ChatConfiguration { AgentsPickerInAttachContextMenu = 'sessions.chat.experimental.agentsPickerInAttachContextMenu', /** Desktop only: lets chat attach a screenshot of the window of another app. */ AppWindowScreenshotsEnabled = 'chat.appWindowScreenshots.enabled', + /** + * Lets an editor window with one local folder move that folder between its own location and the worktrees its agent + * host sessions work in, following the chat that the user opens. + */ + EditorWindowEnableWorktree = 'chat.editorWindow.enableWorktree', PlanAgentDefaultModel = 'chat.planAgent.defaultModel', ExploreAgentDefaultModel = 'chat.exploreAgent.defaultModel', UtilityModel = 'chat.utilityModel', diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts new file mode 100644 index 00000000000000..872709786c3588 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts @@ -0,0 +1,197 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Emitter, Event } from '../../../../../../../base/common/event.js'; +import { toDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IContextKeyService } from '../../../../../../../platform/contextkey/common/contextkey.js'; +import { MockContextKeyService } from '../../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { IFileService, IFileStatWithMetadata } from '../../../../../../../platform/files/common/files.js'; +import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILabelService } from '../../../../../../../platform/label/common/label.js'; +import { ILogService, NullLogService } from '../../../../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../../../../platform/notification/common/notification.js'; +import { TestNotificationService } from '../../../../../../../platform/notification/test/common/testNotificationService.js'; +import { IStorageService } from '../../../../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../../../../platform/telemetry/common/telemetry.js'; +import { TestExperimentTriggerTelemetryService } from '../../../../../../../platform/telemetry/test/common/experimentTriggerTestUtils.js'; +import { IFolderLocationChangeEvent, IResolvedFolderLocation, IWorkspaceFolderLocationService } from '../../../../../../../platform/workspace/common/folderLocations.js'; +import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../../platform/workspace/common/workspace.js'; +import { IWorkbenchEnvironmentService } from '../../../../../../services/environment/common/environmentService.js'; +import { IWorkspaceEditingService } from '../../../../../../services/workspaces/common/workspaceEditing.js'; +import { TestStorageService } from '../../../../../../test/common/workbenchTestServices.js'; +import { ITerminalService } from '../../../../../terminal/browser/terminal.js'; +import { AgentHostWorktreeContribution } from '../../../../browser/agentSessions/agentHost/agentHostWorktree.contribution.js'; +import { IAgentSession } from '../../../../browser/agentSessions/agentSessionsModel.js'; +import { IAgentSessionsService } from '../../../../browser/agentSessions/agentSessionsService.js'; +import { IChatViewHostService, IChatViewHostShownSession } from '../../../../browser/chatViewHost.js'; +import { ChatContextKeys } from '../../../../common/actions/chatContextKeys.js'; +import { SessionType } from '../../../../common/chatSessionsService.js'; +import { ChatConfiguration } from '../../../../common/constants.js'; +import { LocalChatSessionUri } from '../../../../common/model/chatUri.js'; + +suite('AgentHostWorktree', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + const primary = URI.file('/work/vscode'); + const worktree = URI.file('/work/vscode.worktrees/feature'); + const created = URI.file('/work/vscode.worktrees/created'); + + /** The feature in a window whose folder is at `at`, recording the moves it asks for, the chats it opens and its experiment triggers. */ + function createWorktree(options: { readonly enabled: boolean; readonly at?: URI; readonly canSetLocation?: boolean }) { + const shownSession = observableValue('shownSession', undefined); + const workingDirectories = new Map(); + const onDidChangeSessions = store.add(new Emitter()); + const onDidChangeLocation = store.add(new Emitter()); + const workspace = { at: options.at ?? primary }; + const moves: string[] = []; + const opened: string[] = []; + const providers: string[] = []; + const telemetryService = new TestExperimentTriggerTelemetryService(); + const configurationService = new TestConfigurationService({ [ChatConfiguration.EditorWindowEnableWorktree]: options.enabled }); + const contextKeyService = store.add(new MockContextKeyService()); + contextKeyService.createKey(ChatContextKeys.enabled.key, true); + const folder = () => upcastPartial({ uri: workspace.at }); + + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IChatViewHostService, upcastPartial({ + shownSession, + openSession: async (resource: URI) => { opened.push(resource.path); return undefined; }, + })); + instantiationService.stub(IAgentSessionsService, upcastPartial({ + getSession: (resource: URI) => { + const path = workingDirectories.get(resource.toString()); + return path ? upcastPartial({ metadata: { workingDirectoryPath: path } }) : undefined; + }, + model: upcastPartial({ onDidChangeSessions: onDidChangeSessions.event }), + })); + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(IContextKeyService, contextKeyService); + instantiationService.stub(IWorkspaceContextService, upcastPartial({ + getWorkspace: () => upcastPartial({ uri: primary, folders: [folder()] }), + onDidChangeWorkspaceFolders: Event.None, + onDidChangeWorkbenchState: Event.None, + })); + instantiationService.stub(IWorkspaceFolderLocationService, upcastPartial({ + canSetLocation: () => options.canSetLocation ?? true, + onDidChangeLocation: onDidChangeLocation.event, + getLocation: () => workspace.at.path === primary.path ? undefined : upcastPartial({ providerId: 'agentHost.sessionWorktrees' }), + resolveLocation: async (_primary: URI, location: URI) => upcastPartial({ uri: location }), + registerProvider: () => { providers.push('registered'); return toDisposable(() => providers.push('unregistered')); }, + })); + instantiationService.stub(IWorkspaceEditingService, upcastPartial({ + setFolderLocation: async (location: URI, moveOptions?: { readonly ifIdle?: boolean; readonly reason?: string }) => { + moves.push(`${location.path}${moveOptions?.ifIdle ? ' (automatic)' : ''}`); + workspace.at = location; + onDidChangeLocation.fire({ folder: folder(), reason: moveOptions?.reason === 'user' ? 'user' : 'programmatic' }); + }, + })); + instantiationService.stub(IFileService, upcastPartial({ stat: async () => upcastPartial({}) })); + instantiationService.stub(ILabelService, upcastPartial({ getUriLabel: (uri: URI) => uri.path })); + instantiationService.stub(INotificationService, new TestNotificationService()); + instantiationService.stub(ITerminalService, upcastPartial({ instances: [], onDidCreateInstance: Event.None })); + instantiationService.stub(IStorageService, store.add(new TestStorageService())); + instantiationService.stub(ITelemetryService, telemetryService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IWorkbenchEnvironmentService, upcastPartial({ isSessionsWindow: false })); + store.add(instantiationService.createInstance(AgentHostWorktreeContribution)); + + const show = (resource: URI, reason: IChatViewHostShownSession['reason'] = 'open') => shownSession.set({ resource, reason }, undefined); + const setEnabled = (enabled: boolean) => { + configurationService.setUserConfiguration(ChatConfiguration.EditorWindowEnableWorktree, enabled); + configurationService.onDidChangeConfigurationEmitter.fire({ affectsConfiguration: (key: string) => key === ChatConfiguration.EditorWindowEnableWorktree } as never); + }; + const userMovesTo = (location: URI) => { workspace.at = location; onDidChangeLocation.fire({ folder: folder(), reason: 'user' }); }; + return { show, workingDirectories, onDidChangeSessions, moves, opened, providers, triggers: telemetryService.triggers, setEnabled, userMovesTo }; + } + + const settle = () => new Promise(resolve => setTimeout(resolve, 0)); + const agentSession = (id: string) => URI.from({ scheme: SessionType.AgentHostCopilot, path: `/${id}` }); + const localChat = LocalChatSessionUri.forSession('local-1'); + + test('follows a chat the user opens to its worktree and a local chat back to the folder, but not a restored chat', async () => { + const feature = createWorktree({ enabled: true }); + feature.workingDirectories.set(agentSession('a').toString(), worktree.fsPath); + feature.show(agentSession('a'), 'restore'); + await settle(); + const restored = [...feature.moves]; + feature.show(agentSession('a')); + await settle(); + feature.show(localChat); + await settle(); + + assert.deepStrictEqual({ restored, moves: feature.moves, providers: feature.providers, triggers: feature.triggers }, { + restored: [], + moves: ['/work/vscode.worktrees/feature', '/work/vscode'], + providers: ['registered'], + triggers: [`config.${ChatConfiguration.EditorWindowEnableWorktree}`], + }); + }); + + test('follows on its own once the shown session\'s working directory is known, or as it changes, such as to a worktree its agent created', async () => { + const feature = createWorktree({ enabled: true }); + feature.show(agentSession('new')); + await settle(); + const beforeKnown = [...feature.moves]; + feature.workingDirectories.set(agentSession('new').toString(), worktree.fsPath); + feature.onDidChangeSessions.fire(); + await settle(); + feature.workingDirectories.set(agentSession('new').toString(), created.fsPath); + feature.onDidChangeSessions.fire(); + await settle(); + + assert.deepStrictEqual({ beforeKnown, moves: feature.moves }, { + beforeKnown: [], + moves: ['/work/vscode.worktrees/feature (automatic)', '/work/vscode.worktrees/created (automatic)'], + }); + }); + + test('opens the last chat of a location the user moves the folder to themselves', async () => { + const feature = createWorktree({ enabled: true }); + feature.workingDirectories.set(agentSession('a').toString(), worktree.fsPath); + feature.show(agentSession('a')); + await settle(); + feature.show(localChat); + await settle(); + feature.userMovesTo(worktree); + + assert.deepStrictEqual({ moves: feature.moves, opened: feature.opened }, { + moves: ['/work/vscode.worktrees/feature', '/work/vscode'], + opened: ['/a'], + }); + }); + + test('with the setting off, logs the same trigger but moves nothing; turned off at a worktree, returns the folder to its own location', async () => { + const off = createWorktree({ enabled: false }); + off.workingDirectories.set(agentSession('a').toString(), worktree.fsPath); + off.show(agentSession('a')); + await settle(); + + const turnedOff = createWorktree({ enabled: true, at: worktree }); + turnedOff.setEnabled(false); + await settle(); + + const notMovable = createWorktree({ enabled: true, canSetLocation: false }); + notMovable.workingDirectories.set(agentSession('a').toString(), worktree.fsPath); + notMovable.show(agentSession('a')); + await settle(); + + assert.deepStrictEqual({ + off: { moves: off.moves, providers: off.providers, triggers: off.triggers }, + turnedOff: { moves: turnedOff.moves, providers: turnedOff.providers }, + notMovable: { moves: notMovable.moves, triggers: notMovable.triggers }, + }, { + off: { moves: [], providers: [], triggers: [`config.${ChatConfiguration.EditorWindowEnableWorktree}`] }, + turnedOff: { moves: ['/work/vscode'], providers: ['registered', 'unregistered'] }, + notMovable: { moves: [], triggers: [] }, + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFollow.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFollow.test.ts deleted file mode 100644 index c91abc2056add5..00000000000000 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFollow.test.ts +++ /dev/null @@ -1,122 +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 { Emitter } from '../../../../../../../base/common/event.js'; -import { observableValue } from '../../../../../../../base/common/observable.js'; -import { URI } from '../../../../../../../base/common/uri.js'; -import { upcastPartial } from '../../../../../../../base/test/common/mock.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; -import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; -import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; -import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; -import { ILogService, NullLogService } from '../../../../../../../platform/log/common/log.js'; -import { ITelemetryService } from '../../../../../../../platform/telemetry/common/telemetry.js'; -import { TestExperimentTriggerTelemetryService } from '../../../../../../../platform/telemetry/test/common/experimentTriggerTestUtils.js'; -import { IWorkspaceFolderLocationService } from '../../../../../../../platform/workspace/common/folderLocations.js'; -import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../../platform/workspace/common/workspace.js'; -import { IWorkbenchEnvironmentService } from '../../../../../../services/environment/common/environmentService.js'; -import { IWorkspaceEditingService } from '../../../../../../services/workspaces/common/workspaceEditing.js'; -import { AgentHostWorktreeFollowContribution } from '../../../../browser/agentSessions/agentHost/agentHostWorktreeFollow.js'; -// The setting the follow reads and logs its trigger for -import '../../../../browser/agentSessions/agentHost/agentHostWorktreeLocations.js'; -import { IAgentSession } from '../../../../browser/agentSessions/agentSessionsModel.js'; -import { IAgentSessionsService } from '../../../../browser/agentSessions/agentSessionsService.js'; -import { IChatViewHostService, IChatViewHostShownSession } from '../../../../browser/chatViewHost.js'; -import { SessionType } from '../../../../common/chatSessionsService.js'; -import { ChatConfiguration } from '../../../../common/constants.js'; -import { LocalChatSessionUri } from '../../../../common/model/chatUri.js'; - -suite('AgentHostWorktreeFollow', () => { - const store = ensureNoDisposablesAreLeakedInTestSuite(); - - const primary = URI.file('/work/vscode'); - const worktree = URI.file('/work/vscode.worktrees/feature'); - - /** The follow in a window whose folder is at `at`, recording where it moves the folder and its experiment triggers. */ - function createFollow(options: { readonly enabled: boolean; readonly at?: URI; readonly canSetLocation?: boolean }) { - const shownSession = observableValue('shownSession', undefined); - const workingDirectories = new Map(); - const onDidChangeSessions = store.add(new Emitter()); - const moves: string[] = []; - const telemetryService = new TestExperimentTriggerTelemetryService(); - const instantiationService = store.add(new TestInstantiationService()); - instantiationService.stub(IChatViewHostService, upcastPartial({ shownSession })); - instantiationService.stub(IAgentSessionsService, upcastPartial({ - getSession: (resource: URI) => { - const path = workingDirectories.get(resource.toString()); - return path ? upcastPartial({ metadata: { workingDirectoryPath: path } }) : undefined; - }, - model: upcastPartial({ onDidChangeSessions: onDidChangeSessions.event }), - })); - instantiationService.stub(IConfigurationService, new TestConfigurationService({ [ChatConfiguration.EditorWindowEnableWorktree]: options.enabled })); - instantiationService.stub(IWorkspaceContextService, upcastPartial({ - getWorkspace: () => upcastPartial({ uri: primary, folders: [upcastPartial({ uri: options.at ?? primary })] }), - })); - instantiationService.stub(IWorkspaceFolderLocationService, upcastPartial({ canSetLocation: () => options.canSetLocation ?? true })); - instantiationService.stub(IWorkspaceEditingService, upcastPartial({ - setFolderLocation: async (location: URI, moveOptions?: { readonly reason?: string }) => { moves.push(`${location.path} (${moveOptions?.reason})`); }, - })); - instantiationService.stub(ITelemetryService, telemetryService); - instantiationService.stub(ILogService, new NullLogService()); - instantiationService.stub(IWorkbenchEnvironmentService, upcastPartial({ isSessionsWindow: false })); - store.add(instantiationService.createInstance(AgentHostWorktreeFollowContribution)); - const show = (resource: URI, reason: IChatViewHostShownSession['reason'] = 'open') => shownSession.set({ resource, reason }, undefined); - return { show, workingDirectories, onDidChangeSessions, moves, triggers: telemetryService.triggers }; - } - - const agentSession = URI.from({ scheme: SessionType.AgentHostCopilot, path: '/session-1' }); - const localChat = LocalChatSessionUri.forSession('local-1'); - - test('moves the folder to the working directory of an agent host session the user opens, and back for a local chat, but not for a restored chat', () => { - const { show, workingDirectories, moves, triggers } = createFollow({ enabled: true }); - workingDirectories.set(agentSession.toString(), worktree.fsPath); - show(agentSession, 'restore'); - const restored = [...moves]; - show(agentSession); - const opened = [...moves]; - - const atWorktree = createFollow({ enabled: true, at: worktree }); - atWorktree.show(localChat); - atWorktree.show(URI.from({ scheme: SessionType.CopilotCloud, path: '/cloud-1' })); - - assert.deepStrictEqual({ restored, opened, triggers, local: atWorktree.moves }, { - restored: [], - opened: ['/work/vscode.worktrees/feature (user)'], - triggers: [`config.${ChatConfiguration.EditorWindowEnableWorktree}`], - local: ['/work/vscode (user)'], - }); - }); - - test('waits for the working directory of a session that the list does not have yet, and moves nothing where the folder already is', () => { - const { show, workingDirectories, onDidChangeSessions, moves } = createFollow({ enabled: true }); - show(agentSession); - const beforeListed = [...moves]; - workingDirectories.set(agentSession.toString(), worktree.fsPath); - onDidChangeSessions.fire(); - const listed = [...moves]; - show(localChat); - - assert.deepStrictEqual({ beforeListed, listed, localAtPrimary: moves }, { - beforeListed: [], - listed: ['/work/vscode.worktrees/feature (user)'], - localAtPrimary: ['/work/vscode.worktrees/feature (user)'], - }); - }); - - test('with the setting off, logs the same trigger but moves nothing; in a window whose folder cannot move, does neither', () => { - const off = createFollow({ enabled: false }); - off.workingDirectories.set(agentSession.toString(), worktree.fsPath); - off.show(agentSession); - const notMovable = createFollow({ enabled: true, canSetLocation: false }); - notMovable.workingDirectories.set(agentSession.toString(), worktree.fsPath); - notMovable.show(agentSession); - - assert.deepStrictEqual({ off: { moves: off.moves, triggers: off.triggers }, notMovable: { moves: notMovable.moves, triggers: notMovable.triggers } }, { - off: { moves: [], triggers: [`config.${ChatConfiguration.EditorWindowEnableWorktree}`] }, - notMovable: { moves: [], triggers: [] }, - }); - }); -}); From 2dc4f82ef87a22f9811f413f81e1166b86f3290f Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 04:53:18 +0100 Subject: [PATCH 013/165] chat: let a host that replaces the Chat view leave chats to it, and tell whether one does - IChatViewHost.canShow: a host that does not show a chat, such as one that shows only some kinds of chats, leaves revealing, opening and starting it to the Chat view. - IChatViewHostService.replaced: whether a registered host replaces the Chat view. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/chatViewHost.ts | 9 +++++++ .../viewPane/chatViewHostService.ts | 18 ++++++++++--- .../viewPane/chatViewHostService.test.ts | 25 +++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts index 09ba4d76b93ee6..f22b270774fac5 100644 --- a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts +++ b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { IObservable } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { SessionTypeSelectionReason } from '../common/constants.js'; @@ -70,6 +71,11 @@ export interface IChatViewHost { toggle(): Promise; /** Starts showing the session of `sessionResource`, which its caller may send a first request to. */ startSession(sessionResource: URI, options: IChatViewHostSessionStartOptions): Promise; + /** + * Whether the host shows the chat of `sessionResource`. A host that does not leaves revealing, opening and starting it to + * the Chat view, such as a host that shows only some kinds of chats; without it, the host shows every chat. + */ + canShow?(sessionResource: URI): boolean; /** * Starts a new chat in the host, as New Chat of the Chat view does, and returns whether it takes it. A host that does not * implement it leaves New Chat to the chat widget it acts on. @@ -88,6 +94,9 @@ export interface IChatViewHostService extends IChatViewHost { newChat(): boolean; + /** Whether a registered host replaces the Chat view, which then shows only the chats the host leaves to it. */ + readonly replaced: IObservable; + /** * Replaces the Chat view with `host` until the returned disposable is disposed. The host registered last is the one in * use; disposing it gives the use back to the one before. diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts index 7968b5dfa863ba..e8c53e7bbd528b 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts @@ -6,6 +6,7 @@ import { DeferredPromise, raceTimeout } from '../../../../../../base/common/async.js'; import { DisposableStore, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { isEqual } from '../../../../../../base/common/resources.js'; +import { IObservable, observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; @@ -137,17 +138,28 @@ export class ChatViewHostService implements IChatViewHostService { this.chatViewHost = instantiationService.createInstance(ChatViewPaneHost); } + private readonly _replaced = observableValue(this, false); + readonly replaced: IObservable = this._replaced; + private get host(): IChatViewHost { return this.registrations.at(-1)?.host ?? this.chatViewHost; } + /** The host of the chat of `sessionResource`: the Chat view, for a chat the host in use leaves to it. */ + private hostOf(sessionResource: URI): IChatViewHost { + const host = this.host; + return host.canShow?.(sessionResource) === false ? this.chatViewHost : host; + } + registerHost(host: IChatViewHost): IDisposable { const registration = { host }; this.registrations.push(registration); + this._replaced.set(true, undefined); return toDisposable(() => { const index = this.registrations.indexOf(registration); if (index >= 0) { this.registrations.splice(index, 1); + this._replaced.set(this.registrations.length > 0, undefined); } }); } @@ -157,11 +169,11 @@ export class ChatViewHostService implements IChatViewHostService { } async revealIfShowing(sessionResource: URI, options?: IChatViewHostOpenOptions): Promise { - return this.host.revealIfShowing?.(sessionResource, options); + return this.hostOf(sessionResource).revealIfShowing?.(sessionResource, options); } openSession(sessionResource: URI, options?: IChatViewHostOpenOptions): Promise { - return this.host.openSession(sessionResource, options); + return this.hostOf(sessionResource).openSession(sessionResource, options); } toggle(): Promise { @@ -169,7 +181,7 @@ export class ChatViewHostService implements IChatViewHostService { } startSession(sessionResource: URI, options: IChatViewHostSessionStartOptions): Promise { - return this.host.startSession(sessionResource, options); + return this.hostOf(sessionResource).startSession(sessionResource, options); } newChat(): boolean { diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts index f5719606262b3d..6b14e8e837ed54 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts @@ -121,6 +121,31 @@ suite('ChatViewHostService', () => { assert.deepStrictEqual(steps, ['A', 'B', 'C', 'A', 'chat view']); }); + test('a host replaces the Chat view while registered, and leaves to the Chat view the chats it does not show', async () => { + const { service, view, calls } = createService(); + const shown = URI.parse('agent-host-copilot:/session'); + const left = URI.parse('vscode-chat-session://local/session'); + const opened: string[] = []; + const replacedBefore = service.replaced.get(); + const registration = service.registerHost(upcastPartial({ + canShow: resource => resource.scheme !== left.scheme, + openSession: async resource => { opened.push(resource.toString()); return undefined; }, + })); + const replacedWhile = service.replaced.get(); + await service.openSession(shown); + calls.length = 0; + const chatViewWidget = await service.openSession(left); + registration.dispose(); + + assert.deepStrictEqual({ replacedBefore, replacedWhile, replacedAfter: service.replaced.get(), opened, leftToChatView: calls.some(call => call.startsWith('loadSession')) && chatViewWidget === view.widget }, { + replacedBefore: false, + replacedWhile: true, + replacedAfter: false, + opened: ['agent-host-copilot:/session'], + leftToChatView: true, + }); + }); + test('New Chat is left to the chat widget, unless the host that replaces the Chat view starts it', () => { const { service } = createService(); let started = 0; From 9eb3400ac7e7dfa9c2d44a9430b76db3fdcd7890 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:00:46 +0100 Subject: [PATCH 014/165] Editor window: offer New Worktree, the folder and its worktrees in the Chat view's folder chip With chat.editorWindow.enableWorktree on, the folder chip of a new agent host session in a window with a single local folder offers New Worktree first, then the folder and the worktrees that sessions work in, in the shared Folder and Worktrees sections, each with where the folder is now first and described as current. Picking the folder or a worktree makes it the session's working directory; New Worktree has the session work in a new worktree of the folder, which the chip's label shows. A multi-root window's chip is unchanged. The setting's experiment trigger now fires as a chat that the feature can follow shows in a window whose folder can move, which precedes both the chip and any move of the folder. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostChatInputPicker.contribution.ts | 15 +- .../agentHostFolderPickerActionItem.ts | 22 +- .../agentHostWorktree.contribution.ts | 16 +- .../agentHostWorktreeFolderChoices.ts | 192 ++++++++++++++++++ .../agentHostWorktreeFolderChoices.test.ts | 98 +++++++++ 5 files changed, 339 insertions(+), 4 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts index 70df944d52b234..5e7caa7628afa2 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts @@ -9,8 +9,9 @@ import { ClaudeSessionConfigKey } from '../../../../../../platform/agentHost/com import { CodexSessionConfigKey } from '../../../../../../platform/agentHost/common/codexSessionConfigKeys.js'; import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { ContextKeyExpr } from '../../../../../../platform/contextkey/common/contextkey.js'; -import { IsSessionsWindowContext, WorkspaceFolderCountContext } from '../../../../../common/contextkeys.js'; +import { IsSessionsWindowContext, RemoteNameContext, WorkbenchStateContext, WorkspaceFolderCountContext } from '../../../../../common/contextkeys.js'; import { ChatContextKeys, ChatContextKeyExprs } from '../../../common/actions/chatContextKeys.js'; +import { ChatConfiguration } from '../../../common/constants.js'; import { SessionType } from '../../../common/chatSessionsService.js'; const modeAndPermissionsWhen = ContextKeyExpr.and( @@ -68,6 +69,18 @@ export class OpenAgentHostFolderPickerAction extends Action2 { // so the chip never flashes visible-then-hidden while the decision is resolving. ChatContextKeys.chatAgentHostFolderPickerVisible, ), + }, { + id: MenuId.ChatInputSecondary, + group: 'navigation', + order: 1.1, + // With worktree support, a window with a single local folder offers New Worktree, the folder and its worktrees + when: ContextKeyExpr.and( + ChatContextKeyExprs.isAgentHostSession, + ContextKeyExpr.equals(`config.${ChatConfiguration.EditorWindowEnableWorktree}`, true), + WorkbenchStateContext.isEqualTo('folder'), + RemoteNameContext.isEqualTo(''), + IsSessionsWindowContext.negate(), + ), }], }); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts index 9cfadee2a576a2..3ab70300c092d5 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts @@ -19,6 +19,7 @@ import { IActionWidgetService } from '../../../../../../platform/actionWidget/br import { IActionWidgetDropdownAction, IActionWidgetDropdownActionProvider, IActionWidgetDropdownOptions } from '../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; import { IStorageService } from '../../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; @@ -29,6 +30,7 @@ import { ChatInputPickerActionViewItem, IChatInputPickerOptions } from '../../wi import { IAgentHostNewSessionFolderService } from './agentHostNewSessionFolderService.js'; import { IAgentHostCustomizationService } from './agentHostCustomizationService.js'; import { createFolderPickerTip } from './agentHostFolderPickerTip.js'; +import { AgentHostWorktreeFolderChoices } from './agentHostWorktreeFolderChoices.js'; /** * Folder picker for agent-host sessions in multi-root windows. An agent-host @@ -43,6 +45,8 @@ import { createFolderPickerTip } from './agentHostFolderPickerTip.js'; export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewItem { private _hoverSetup = false; + /** The choices in a window with a single local folder while worktree support is on: New Worktree, the folder and its worktrees. */ + private readonly _worktree: AgentHostWorktreeFolderChoices; constructor( action: MenuItemAction, @@ -58,10 +62,18 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt @ISCMService private readonly _scmService: ISCMService, @IHoverService private readonly _hoverService: IHoverService, @IStorageService storageService: IStorageService, + @IInstantiationService instantiationService: IInstantiationService, ) { const actionProvider: IActionWidgetDropdownActionProvider = { getActions: () => { const selected = this._selectedFolder(); + if (this._worktree.isActive()) { + return this._worktree.getActions(action, selected, () => { + if (this.element) { + this.renderLabel(this.element); + } + }); + } return this._workspaceContextService.getWorkspace().folders.map(folder => ({ ...action, id: `agentHostFolder.${folder.uri.toString()}`, @@ -97,6 +109,13 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt super(action, folderPickerOptions, pickerOptions, actionWidgetService, keybindingService, contextKeyService, telemetryService); + this._worktree = this._register(instantiationService.createInstance(AgentHostWorktreeFolderChoices, () => this._sessionResource())); + this._register(this._worktree.onDidChange(() => { + if (this.element) { + this.renderLabel(this.element); + } + })); + this._register(this._newSessionFolderService.onDidChangeFolder(() => { if (this.element) { this.renderLabel(this.element); @@ -108,6 +127,7 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt } })); this._register(this._widget.onDidChangeViewModel(() => { + this._worktree.refresh(); if (this.element) { this.renderLabel(this.element); } @@ -149,7 +169,7 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt protected override renderLabel(element: HTMLElement): IDisposable | null { const selected = this._selectedFolder(); const folder = selected && this._workspaceContextService.getWorkspace().folders.find(f => f.uri.toString() === selected.toString()); - const label = folder ? folder.name : (selected ? basename(selected) : localize('agentHost.selectFolder', "Folder")); + const label = selected && this._worktree?.isActive() ? this._worktree.getLabel(selected) : folder ? folder.name : (selected ? basename(selected) : localize('agentHost.selectFolder', "Folder")); const compact = this.pickerOptions.compact.get(); element.classList.toggle('icon-only', compact); dom.reset( diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts index 3f4870b351f13e..a8cc4223d99bdc 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts @@ -145,6 +145,9 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe const shown = _chatViewHostService.shownSession.read(reader); this._sessionsListener.clear(); this._shown = shown && !isUntitledChatSession(shown.resource) ? { resource: shown.resource, workingDirectory: undefined } : undefined; + if (shown) { + this._logTrigger(shown.resource); + } if (this._shown && shown?.reason === 'open') { this._foreground++; this._follow(this._shown, false); @@ -254,14 +257,23 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe if (!target || target.scheme !== Schemas.file || extUriBiasedIgnorePathCase.isEqualOrParent(target, folder.uri)) { return; } - // The folder would move here: the same moment with the setting on and off - logSettingExperimentTrigger(this._telemetryService, ChatConfiguration.EditorWindowEnableWorktree); if (!this._enabled || (automatic && this._switching > 0)) { return; } void this._switch(shown.resource, primary, target, automatic ? this._foreground : undefined); } + /** + * Logs the setting's experiment trigger as a chat that it can follow shows in a window whose folder can move, before the + * folder follows it or the chat's folder chip offers worktrees: the same moment with the setting on and off. + */ + private _logTrigger(resource: URI): void { + const folder = this._workspaceContextService.getWorkspace().folders[0]; + if (folder && this._folderLocationService.canSetLocation(folder) && (LocalChatSessionUri.isLocalSession(resource) || isLocalAgentHostTarget(resource.scheme))) { + logSettingExperimentTrigger(this._telemetryService, ChatConfiguration.EditorWindowEnableWorktree); + } + } + /** @param foreground of an automatic switch, the {@link _foreground} it started at. */ private async _switch(session: URI, primary: URI, target: URI, foreground: number | undefined): Promise { const automatic = foreground !== undefined; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts new file mode 100644 index 00000000000000..7fd78cb491eea9 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts @@ -0,0 +1,192 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { onUnexpectedError } from '../../../../../../base/common/errors.js'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { Disposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { basename, extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { localize } from '../../../../../../nls.js'; +import { getSessionIsolationProperty } from '../../../../../../platform/agentHost/common/sessionConfigProperties.js'; +import { MenuItemAction } from '../../../../../../platform/actions/common/actions.js'; +import { IActionWidgetDropdownAction } from '../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IFolderLocation, IWorkspaceFolderLocationService } from '../../../../../../platform/workspace/common/folderLocations.js'; +import { IWorkspaceContextService, WorkbenchState } from '../../../../../../platform/workspace/common/workspace.js'; +import { getCurrentFolderLocationDescription, getNewWorktreeLabel, groupFolderLocations } from '../../../../../services/workspaces/common/folderLocationSections.js'; +import { ChatConfiguration } from '../../../common/constants.js'; +import { IAgentHostNewSessionFolderService } from './agentHostNewSessionFolderService.js'; +import { getLocalAgentHostSessionProvider } from './agentHostSessionUri.js'; +import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; + +/** What the folder chip shows for a folder, and which one it is. */ +interface IFolderChoice { + readonly uri: URI; + readonly label: string; + readonly kind: 'folder' | 'worktree'; + readonly current: boolean; +} + +/** + * The choices of the folder chip of a new agent host session in a window with a single local folder, while + * `chat.editorWindow.enableWorktree` is on: New Worktree, then the window's folder and the worktrees that sessions work in, + * in the sections that every such picker shows (see `groupFolderLocations`). Picking the folder or a worktree makes it + * the session's working directory; New Worktree has the session work in a new worktree of the folder. + */ +export class AgentHostWorktreeFolderChoices extends Disposable { + + /** The folder's worktrees, read ahead of time, as the chip asks for its choices synchronously. */ + private _locations: readonly IFolderLocation[] = []; + private readonly _refresh = this._register(new MutableDisposable()); + + private readonly _onDidChange = this._register(new Emitter()); + /** Fires as its choices or the selected one change. */ + readonly onDidChange = this._onDidChange.event; + + constructor( + private readonly _sessionResource: () => URI | undefined, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IWorkspaceFolderLocationService private readonly _folderLocationService: IWorkspaceFolderLocationService, + @IAgentHostNewSessionFolderService private readonly _newSessionFolderService: IAgentHostNewSessionFolderService, + @IAgentHostUntitledProvisionalSessionService private readonly _provisional: IAgentHostUntitledProvisionalSessionService, + ) { + super(); + this._register(_folderLocationService.onDidChangeProviders(() => this.refresh())); + this._register(_folderLocationService.onDidChangeLocation(() => this.refresh())); + this._register(_provisional.onDidChange(() => this._onDidChange.fire())); + this.refresh(); + } + + /** Whether the chip offers these choices: in a window with a single local folder, while the setting is on. */ + isActive(): boolean { + const folder = this._workspaceContextService.getWorkspace().folders[0]; + return !!this._configurationService.getValue(ChatConfiguration.EditorWindowEnableWorktree) + && this._workspaceContextService.getWorkbenchState() === WorkbenchState.FOLDER + && !!folder && !!this._workspaceContextService.getWorkspace().uri && this._folderLocationService.canSetLocation(folder); + } + + /** Reads the folder's worktrees again, such as one that a session created since. */ + refresh(): void { + const primary = this._workspaceContextService.getWorkspace().uri; + if (!primary || !this.isActive()) { + this._refresh.clear(); + this._locations = []; + return; + } + const source = new CancellationTokenSource(); + this._refresh.value = toDisposable(() => source.dispose(true)); + this._folderLocationService.getLocations(primary, source.token).then(locations => { + if (!source.token.isCancellationRequested) { + this._locations = locations; + this._onDidChange.fire(); + } + }, onUnexpectedError); + } + + /** Whether the session is to work in a new worktree of its folder. */ + isNewWorktreeSelected(): boolean { + const isolation = this._isolation(); + return !!isolation && isolation.values[isolation.key] === 'worktree'; + } + + /** The label of the chip for the selected `folder`: by its name, or as a new worktree of it. */ + getLabel(folder: URI): string { + const name = this._choices().find(choice => extUriBiasedIgnorePathCase.isEqual(choice.uri, folder))?.label ?? basename(folder); + return this.isNewWorktreeSelected() ? localize('agentHost.folderPicker.newWorktree', "{0} - New Worktree", name) : name; + } + + /** The chip's choices, `selected` checked, unless the session is to work in a new worktree. */ + getActions(action: MenuItemAction, selected: URI | undefined, onDidPick: () => void): IActionWidgetDropdownAction[] { + const newWorktreeSelected = this.isNewWorktreeSelected(); + const actions: IActionWidgetDropdownAction[] = []; + if (this._isolation()?.worktree) { + actions.push({ + ...action, + id: 'agentHostFolder.newWorktree', + label: getNewWorktreeLabel(), + tooltip: localize('agentHost.folderPicker.newWorktreeDescription', "Start the session in a new worktree of the selected folder."), + checked: newWorktreeSelected, + icon: Codicon.gitBranchCheckout, + enabled: true, + category: { label: '', order: 0 }, + run: async () => { + await this._setIsolation(true); + onDidPick(); + }, + }); + } + const sections = groupFolderLocations(this._choices().map(choice => ({ item: choice, kind: choice.kind, current: choice.current }))); + sections.forEach((section, index) => { + for (const choice of section.items) { + actions.push({ + ...action, + id: `agentHostFolder.${choice.uri.toString()}`, + label: choice.label, + description: choice.current ? getCurrentFolderLocationDescription() : undefined, + checked: !newWorktreeSelected && !!selected && extUriBiasedIgnorePathCase.isEqual(selected, choice.uri), + icon: choice.kind === 'worktree' ? Codicon.gitBranchCheckout : Codicon.folder, + enabled: true, + tooltip: choice.uri.fsPath, + category: { label: section.title, order: index + 1, showHeader: true }, + run: async () => { + const sessionResource = this._sessionResource(); + if (sessionResource) { + this._newSessionFolderService.setFolder(sessionResource, choice.uri); + } + await this._setIsolation(false); + onDidPick(); + }, + }); + } + }); + return actions; + } + + /** The window's folder at its own location, and its worktrees, with where the folder is now. */ + private _choices(): IFolderChoice[] { + const workspace = this._workspaceContextService.getWorkspace(); + const primary = workspace.uri; + const folder = workspace.folders[0]; + if (!primary || !folder) { + return []; + } + const isCurrent = (uri: URI) => extUriBiasedIgnorePathCase.isEqual(uri, folder.uri); + return [ + { uri: primary, label: basename(primary), kind: 'folder', current: isCurrent(primary) }, + ...this._locations + .filter(location => !extUriBiasedIgnorePathCase.isEqual(location.uri, primary)) + .map(location => ({ uri: location.uri, label: location.label, kind: 'worktree' as const, current: isCurrent(location.uri) })), + ]; + } + + /** The session's isolation property and values, with whether it can work in a new worktree. */ + private _isolation(): { readonly key: string; readonly values: Readonly>; readonly worktree: boolean; readonly inPlace: string | undefined } | undefined { + const sessionResource = this._sessionResource(); + const config = sessionResource && (this._provisional.getResolvedConfig(sessionResource) ?? this._provisional.getPreparedSessionConfig(sessionResource)); + const property = config ? getSessionIsolationProperty(config.schema) : undefined; + if (!config || !property) { + return undefined; + } + const values = property.schema.enum ?? []; + return { key: property.key, values: config.values, worktree: values.includes('worktree'), inPlace: values.find(value => value !== 'worktree') as string | undefined }; + } + + /** Has the session work in a new worktree of its folder, or in the folder itself. */ + private async _setIsolation(worktree: boolean): Promise { + const sessionResource = this._sessionResource(); + const provider = sessionResource && getLocalAgentHostSessionProvider(sessionResource); + const isolation = this._isolation(); + const value = worktree ? (isolation?.worktree ? 'worktree' : undefined) : isolation?.inPlace; + if (!sessionResource || !provider || !isolation || !value || isolation.values[isolation.key] === value) { + return; + } + const workingDirectory = this._newSessionFolderService.getFolder(sessionResource) ?? this._workspaceContextService.getWorkspace().folders[0]?.uri; + await this._provisional.applyConfigChange(sessionResource, provider, workingDirectory, { [isolation.key]: value }); + this._onDidChange.fire(); + } +} diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts new file mode 100644 index 00000000000000..450f6da2c9f062 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Event } from '../../../../../../../base/common/event.js'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { MenuItemAction } from '../../../../../../../platform/actions/common/actions.js'; +import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IFolderLocation, IWorkspaceFolderLocationService } from '../../../../../../../platform/workspace/common/folderLocations.js'; +import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder, WorkbenchState } from '../../../../../../../platform/workspace/common/workspace.js'; +import { IAgentHostNewSessionFolderService } from '../../../../browser/agentSessions/agentHost/agentHostNewSessionFolderService.js'; +import { IAgentHostUntitledProvisionalSessionService } from '../../../../browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; +import { AgentHostWorktreeFolderChoices } from '../../../../browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.js'; +import { SessionType } from '../../../../common/chatSessionsService.js'; +import { ChatConfiguration } from '../../../../common/constants.js'; + +suite('AgentHostWorktreeFolderChoices', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + const primary = URI.file('/work/vscode'); + const feature = URI.file('/work/vscode.worktrees/feature'); + const other = URI.file('/work/vscode.worktrees/other'); + const session = URI.from({ scheme: SessionType.AgentHostCopilot, path: '/untitled-1' }); + + /** The choices in a window whose folder is at `at`, recording the folders and isolation it sets for the session. */ + async function createChoices(options: { readonly enabled: boolean; readonly at: URI; readonly isolation?: string; readonly state?: WorkbenchState }) { + const config = { values: { isolation: options.isolation ?? 'folder' } as Record }; + const changes: string[] = []; + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IConfigurationService, new TestConfigurationService({ [ChatConfiguration.EditorWindowEnableWorktree]: options.enabled })); + instantiationService.stub(IWorkspaceContextService, upcastPartial({ + getWorkspace: () => upcastPartial({ uri: primary, folders: [upcastPartial({ uri: options.at })] }), + getWorkbenchState: () => options.state ?? WorkbenchState.FOLDER, + })); + instantiationService.stub(IWorkspaceFolderLocationService, upcastPartial({ + onDidChangeProviders: Event.None, + onDidChangeLocation: Event.None, + canSetLocation: () => true, + getLocations: async () => [feature, other].map(uri => upcastPartial({ uri, label: uri.path.split('/').pop() })), + })); + instantiationService.stub(IAgentHostNewSessionFolderService, upcastPartial({ + getFolder: () => undefined, + setFolder: (_resource: URI, folder: URI) => { changes.push(`folder ${folder.path}`); }, + })); + instantiationService.stub(IAgentHostUntitledProvisionalSessionService, upcastPartial({ + onDidChange: Event.None, + getResolvedConfig: () => ({ schema: { type: 'object', properties: { isolation: { type: 'string', title: 'Isolation', enum: ['folder', 'worktree'] } } }, values: config.values }), + getPreparedSessionConfig: () => undefined, + applyConfigChange: async (_resource: URI, _provider: string, _workingDirectory: URI | undefined, partial: Record) => { + changes.push(`isolation ${partial.isolation}`); + config.values = { ...config.values, ...partial }; + return session; + }, + })); + const choices = store.add(instantiationService.createInstance(AgentHostWorktreeFolderChoices, () => session)); + await new Promise(resolve => setTimeout(resolve, 0)); + const actions = (selected: URI) => choices.getActions(upcastPartial({}), selected, () => { }); + const items = (selected: URI) => actions(selected).map(action => `${action.category?.label ? `[${action.category.label}] ` : ''}${action.label}${action.description ? ` (${action.description})` : ''}${action.checked ? ' *' : ''}`); + return { choices, actions, items, changes }; + } + + test('offers New Worktree first, then the folder and its worktrees, each section with where the folder is now first', async () => { + const atWorktree = await createChoices({ enabled: true, at: other }); + const atFolder = await createChoices({ enabled: true, at: primary }); + + assert.deepStrictEqual({ atWorktree: atWorktree.items(other), atFolder: atFolder.items(primary) }, { + atWorktree: ['New Worktree', '[Folder] vscode', '[Worktrees] other (Current) *', '[Worktrees] feature'], + atFolder: ['New Worktree', '[Folder] vscode (Current) *', '[Worktrees] feature', '[Worktrees] other'], + }); + }); + + test('New Worktree has the session work in a new worktree, which the label shows, and picking a folder again works in it', async () => { + const { choices, actions, items, changes } = await createChoices({ enabled: true, at: primary }); + await actions(primary).find(action => action.label === 'New Worktree')!.run(); + const inNewWorktree = { items: items(primary), label: choices.getLabel(primary) }; + await actions(primary).find(action => action.label === 'feature')!.run(); + + assert.deepStrictEqual({ inNewWorktree, changes, label: choices.getLabel(feature) }, { + inNewWorktree: { items: ['New Worktree *', '[Folder] vscode (Current)', '[Worktrees] feature', '[Worktrees] other'], label: 'vscode - New Worktree' }, + changes: ['isolation worktree', 'folder /work/vscode.worktrees/feature', 'isolation folder'], + label: 'feature', + }); + }); + + test('is active only with the setting on in a window with a single local folder', async () => { + assert.deepStrictEqual({ + on: (await createChoices({ enabled: true, at: primary })).choices.isActive(), + off: (await createChoices({ enabled: false, at: primary })).choices.isActive(), + multiRoot: (await createChoices({ enabled: true, at: primary, state: WorkbenchState.WORKSPACE })).choices.isActive(), + }, { on: true, off: false, multiRoot: false }); + }); +}); From 7a4f1914daa0ac495117c8d3320765671fcf77a4 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:00:48 +0100 Subject: [PATCH 015/165] Sessions: the sessions runtime of editor windows Registers the sessions services that the sessions UI the Agents window shares with editor windows injects, so a feature (a sessions list, the chat part) only enables what it shows, without a setting here: - 16 services of the Agents window's sessions UI, all created on first use, and the window's session host, which does nothing until a feature binds what it shows. - ISessionsService and ISessionsNavigationService show chats through IEditorWindowSessionsPresenterService: the presenter registered last, such as the chat part, or else the Chat view. - IEditorWindowSessionsRuntime.enable({ viewIds }) holds the sessions providers while a view of an owner, or the chat part, shows. It replaces the setting-gated contribution of the Sessions view. - A static check that an editor window provides every service the sessions code it loads injects. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/editorWindowSessionsServices.test.ts | 280 +++++++++++++++++ .../sessionsPresentationDependencies.test.ts | 36 +-- build/lib/test/sourceDependencies.ts | 54 ++++ eslint.config.js | 1 + .../browser/agentsView/agentsViewProviders.ts | 83 +++++ .../agentsView/editorWindowMainSessionHost.ts | 52 ++++ .../editorWindowSessionsNavigationHost.ts | 80 +++++ .../agentsView/editorWindowSessionsService.ts | 291 ++++++++++++++++++ .../editorWindowSessions.contribution.ts | 54 ++++ .../editorWindowSessionsPresenter.ts | 129 ++++++++ .../editorWindowSessionsRuntime.ts | 107 +++++++ .../browser/agentsViewComposerControls.ts | 33 ++ .../agentsViewProviders.contribution.ts | 103 +++++++ .../agentsViewProviders.contribution.test.ts | 116 +++++++ .../test/browser/agentsViewServices.test.ts | 107 +++++++ .../agentsView/agentsViewProviders.test.ts | 83 +++++ .../editorWindowSessionsRuntime.test.ts | 88 ++++++ src/vs/workbench/workbench.common.main.ts | 1 + 18 files changed, 1663 insertions(+), 35 deletions(-) create mode 100644 build/lib/test/editorWindowSessionsServices.test.ts create mode 100644 build/lib/test/sourceDependencies.ts create mode 100644 src/vs/workbench/contrib/sessions/browser/agentsView/agentsViewProviders.ts create mode 100644 src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowMainSessionHost.ts create mode 100644 src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsNavigationHost.ts create mode 100644 src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsService.ts create mode 100644 src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsPresenter.ts create mode 100644 src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsRuntime.ts create mode 100644 src/vs/workbench/contrib/sessions/providers/agentsView/browser/agentsViewComposerControls.ts create mode 100644 src/vs/workbench/contrib/sessions/providers/agentsView/browser/agentsViewProviders.contribution.ts create mode 100644 src/vs/workbench/contrib/sessions/providers/agentsView/test/browser/agentsViewProviders.contribution.test.ts create mode 100644 src/vs/workbench/contrib/sessions/providers/agentsView/test/browser/agentsViewServices.test.ts create mode 100644 src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsViewProviders.test.ts create mode 100644 src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts diff --git a/build/lib/test/editorWindowSessionsServices.test.ts b/build/lib/test/editorWindowSessionsServices.test.ts new file mode 100644 index 00000000000000..959fb20a845a95 --- /dev/null +++ b/build/lib/test/editorWindowSessionsServices.test.ts @@ -0,0 +1,280 @@ +/*--------------------------------------------------------------------------------------------- + * 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 fs from 'fs'; +import path from 'path'; +import { suite, test } from 'node:test'; +import ts from 'typescript'; +import { collectSourceDependencies, repositoryRoot, resolveSourceImport } from './sourceDependencies.ts'; + +const editorWindowEntry = 'src/vs/workbench/workbench.desktop.main.ts'; + +/** The files that set up the main service collection of an editor window, before any registered singleton. */ +const mainServiceCollectionFiles = ['src/vs/workbench/electron-browser/desktop.main.ts', 'src/vs/workbench/browser/workbench.ts']; + +/** The sessions code shared with the Agents window, which can depend on services that only the Agents window registers. */ +const sessionsRoots = ['src/vs/workbench/contrib/sessions/', 'src/vs/workbench/services/sessions/']; + +/** + * The services that an instantiation service scoped to a part of the sessions UI provides, rather than the window: their + * consumers are created through that scope only. + */ +const scopedServices = new Set([ + // The model and picker state of a chat input composer, for its toolbar's pickers (see SharedChatInputComposer) + 'newChatModelPickerService', 'sessionModelSelection', 'sessionInputPickerVisibility', +]); + +const registrationFunctions = new Set(['registerSingleton', 'registerMainProcessRemoteService', 'registerSharedProcessRemoteService']); + +const sourceFiles = new Map(); + +function parse(file: string): ts.SourceFile { + let source = sourceFiles.get(file); + if (!source) { + source = ts.createSourceFile(file, fs.readFileSync(path.join(repositoryRoot, file), 'utf8'), ts.ScriptTarget.Latest, true); + sourceFiles.set(file, source); + } + return source; +} + +function forEachDescendant(node: ts.Node, callback: (node: ts.Node) => void): void { + node.forEachChild(child => { + callback(child); + forEachDescendant(child, callback); + }); +} + +interface IDeclaration { + readonly file: string; + readonly node: ts.ClassDeclaration | ts.VariableDeclaration; +} + +const declarations = new Map(); + +/** The class or variable that `name` refers to in `file`: declared there, or imported, also through re-exports. */ +function findDeclaration(file: string, name: string): IDeclaration | undefined { + const key = `${file}#${name}`; + if (declarations.has(key)) { + return declarations.get(key); + } + declarations.set(key, undefined); + let result: IDeclaration | undefined; + const source = parse(file); + for (const statement of source.statements) { + if (ts.isClassDeclaration(statement) && statement.name?.text === name) { + result = { file, node: statement }; + } else if (ts.isVariableStatement(statement)) { + const declaration = statement.declarationList.declarations.find(candidate => ts.isIdentifier(candidate.name) && candidate.name.text === name); + if (declaration) { + result = { file, node: declaration }; + } + } + if (result) { + break; + } + } + for (const statement of source.statements) { + if (result) { + break; + } + if (!(ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) || !statement.moduleSpecifier || !ts.isStringLiteral(statement.moduleSpecifier) || !statement.moduleSpecifier.text.startsWith('.')) { + continue; + } + const bindings = ts.isImportDeclaration(statement) ? statement.importClause?.namedBindings : statement.exportClause; + const target = resolveSourceImport(file, statement.moduleSpecifier.text); + if (bindings && (ts.isNamedImports(bindings) || ts.isNamedExports(bindings))) { + const element = bindings.elements.find(candidate => candidate.name.text === name); + if (element) { + result = findDeclaration(target, (element.propertyName ?? element.name).text); + } + } else if (ts.isExportDeclaration(statement) && !bindings) { + result = findDeclaration(target, name); + } + } + declarations.set(key, result); + return result; +} + +/** The ID of the service decorator `name` in `file`, which a refined decorator shares with the decorator it refines. */ +function serviceIdOf(file: string, name: string): string | undefined { + const declaration = findDeclaration(file, name); + if (!declaration || !ts.isVariableDeclaration(declaration.node)) { + return undefined; + } + const initializer = declaration.node.initializer; + if (!initializer || !ts.isCallExpression(initializer) || !ts.isIdentifier(initializer.expression)) { + return undefined; + } + const [argument] = initializer.arguments; + if (initializer.expression.text === 'createDecorator' && argument && ts.isStringLiteral(argument)) { + return argument.text; + } + if (initializer.expression.text === 'refineServiceDecorator' && argument && ts.isIdentifier(argument)) { + return serviceIdOf(declaration.file, argument.text); + } + return undefined; +} + +/** Whether `node` is within a class, rather than at the top level of its module, such as in a function there. */ +function isInClass(node: ts.Node): boolean { + for (let parent = node.parent; parent; parent = parent.parent) { + if (ts.isClassLike(parent)) { + return true; + } + } + return false; +} + +/** The classes that the `createInstance` calls within `node` create. */ +function createdClassesOf(node: ts.Node): string[] { + const created: string[] = []; + forEachDescendant(node, descendant => { + if (ts.isCallExpression(descendant) && ts.isPropertyAccessExpression(descendant.expression) && descendant.expression.name.text === 'createInstance') { + const name = classNameOf(descendant.arguments[0]); + if (name) { + created.push(name); + } + } + }); + return created; +} + +/** The class that `expression`, such as the first argument of `createInstance`, names. */ +function classNameOf(expression: ts.Expression | undefined): string | undefined { + if (expression && ts.isExpressionWithTypeArguments(expression)) { + expression = expression.expression; + } + return expression && ts.isIdentifier(expression) ? expression.text : undefined; +} + +interface IClassReference { + readonly file: string; + readonly name: string; +} + +interface IEditorWindowServices { + /** The IDs of the services that an editor window provides. */ + readonly provided: Set; + /** The implementations that an editor window registers, by service ID. */ + readonly implementations: Map; + /** The classes that the sessions code of an editor window registers, or creates outside of classes. */ + readonly roots: IClassReference[]; +} + +function collectEditorWindowServices(): IEditorWindowServices { + const provided = new Set(['instantiationService']); + const implementations = new Map(); + const roots: IClassReference[] = []; + for (const file of mainServiceCollectionFiles) { + forEachDescendant(parse(file), node => { + if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'set' && node.arguments[0] && ts.isIdentifier(node.arguments[0])) { + const id = serviceIdOf(file, node.arguments[0].text); + if (id) { + provided.add(id); + } + } + }); + } + for (const file of collectSourceDependencies(editorWindowEntry)) { + if (!file.startsWith('src/') || !file.endsWith('.ts') || file.endsWith('.d.ts')) { + continue; + } + const isSessions = sessionsRoots.some(root => file.startsWith(root)); + const text = fs.readFileSync(path.join(repositoryRoot, file), 'utf8'); + if (!/register(Singleton|MainProcessRemoteService|SharedProcessRemoteService)\(/.test(text) && !isSessions) { + continue; + } + forEachDescendant(parse(file), node => { + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && registrationFunctions.has(node.expression.text) && node.arguments[0] && ts.isIdentifier(node.arguments[0])) { + const id = serviceIdOf(file, node.arguments[0].text); + if (!id) { + return; + } + provided.add(id); + const implementation = classNameOf(node.arguments[1]); + if (implementation) { + const references = implementations.get(id) ?? []; + references.push({ file, name: implementation }); + implementations.set(id, references); + } + } + if (!isSessions) { + return; + } + // What the sessions code registers or creates outside of classes: services, contributions, views and editors. A + // class creates what it creates once it is created itself. + let root: string | undefined; + if (ts.isCallExpression(node)) { + const callee = ts.isPropertyAccessExpression(node.expression) ? node.expression.name.text : ts.isIdentifier(node.expression) ? node.expression.text : undefined; + if (callee === 'createInstance' && !isInClass(node)) { + root = classNameOf(node.arguments[0]); + } else if (callee === 'registerSingleton' || callee === 'registerWorkbenchContribution2') { + root = classNameOf(node.arguments[1]); + } + } else if (ts.isNewExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'SyncDescriptor') { + root = classNameOf(node.arguments?.[0]); + } + if (root) { + roots.push({ file, name: root }); + } + }); + } + return { provided, implementations, roots }; +} + +/** + * The services that the classes of `roots` inject, through their constructors and those of their base classes, and of + * the classes they create and the editor window's implementations of the services they inject, within the sessions code: + * each with the class that injects it. + */ +function collectInjectedServices(roots: readonly IClassReference[], implementations: Map): Map { + const injected = new Map(); + const visited = new Set(); + const pending = [...roots]; + while (pending.length > 0) { + const reference = pending.pop()!; + const declaration = findDeclaration(reference.file, reference.name); + if (!declaration || !ts.isClassDeclaration(declaration.node) || visited.has(declaration.node) || !sessionsRoots.some(root => declaration.file.startsWith(root))) { + continue; + } + visited.add(declaration.node); + const className = `${declaration.node.name?.text} (${declaration.file})`; + const constructor = declaration.node.members.find(ts.isConstructorDeclaration); + for (const parameter of constructor?.parameters ?? []) { + for (const decorator of ts.getDecorators(parameter) ?? []) { + const id = ts.isIdentifier(decorator.expression) ? serviceIdOf(declaration.file, decorator.expression.text) : undefined; + if (!id) { + continue; + } + if (!injected.has(id)) { + injected.set(id, className); + } + pending.push(...implementations.get(id) ?? []); + } + } + pending.push(...createdClassesOf(declaration.node).map(name => ({ file: declaration.file, name }))); + for (const clause of declaration.node.heritageClauses ?? []) { + if (clause.token === ts.SyntaxKind.ExtendsKeyword) { + const base = classNameOf(clause.types[0]); + if (base) { + pending.push({ file: declaration.file, name: base }); + } + } + } + } + return injected; +} + +suite('Editor window sessions services', () => { + + test('an editor window provides every service that the sessions code it loads injects, also those that only the Agents window registers', () => { + const { provided, implementations, roots } = collectEditorWindowServices(); + const injected = collectInjectedServices(roots, implementations); + + const missing = [...injected].filter(([id]) => !provided.has(id) && !scopedServices.has(id)).map(([id, consumer]) => `${id}, injected by ${consumer}`).sort(); + assert.deepStrictEqual(missing, []); + }); +}); diff --git a/build/lib/test/sessionsPresentationDependencies.test.ts b/build/lib/test/sessionsPresentationDependencies.test.ts index 05dab84a64d901..ece9ae9dd5412c 100644 --- a/build/lib/test/sessionsPresentationDependencies.test.ts +++ b/build/lib/test/sessionsPresentationDependencies.test.ts @@ -9,8 +9,8 @@ import path from 'path'; import { suite, test } from 'node:test'; import ts from 'typescript'; import { getResourcePaths } from '../../next/resources.ts'; +import { collectSourceDependencies, repositoryRoot } from './sourceDependencies.ts'; -const repositoryRoot = path.resolve(import.meta.dirname, '../../..'); const hostPickerRoot = 'src/vs/sessions/contrib/providers/remoteAgentHost/browser/'; const sharedHostPickerRoot = 'src/vs/workbench/contrib/sessions/providers/remoteAgentHost/browser/'; const originalPicker = [ @@ -28,7 +28,6 @@ const experimentalPicker = [ 'media/hostPlaceChip.css', 'media/mobileHostPickerSheet.css', ].map(file => hostPickerRoot + file); -const dependenciesByFile = new Map(); const composerRoot = 'src/vs/sessions/contrib/providers/agentHost/browser/mobile/'; const originalConfigurationPresenter = 'src/vs/workbench/contrib/sessions/providers/agentHost/browser/mobile/mobileChatPhoneInputPresenter.ts'; const mobileConfigurationPresenter = composerRoot + 'mobileChatPhoneInputPresenter.ts'; @@ -329,36 +328,3 @@ function isMobileEntryFile(file: string): boolean { hostPickerRoot + 'mobileTunnelConnection.ts', ].includes(file); } - -/** Includes type imports conservatively: neither source graph may depend on the other presentation. */ -function collectSourceDependencies(entry: string): Set { - const visited = new Set(); - const pending = [entry]; - while (pending.length > 0) { - const file = pending.pop()!; - if (visited.has(file)) { - continue; - } - visited.add(file); - if (!file.endsWith('.ts') && !file.endsWith('.js')) { - continue; - } - let dependencies = dependenciesByFile.get(file); - if (!dependencies) { - const source = fs.readFileSync(path.join(repositoryRoot, file), 'utf8'); - dependencies = ts.preProcessFile(source, true).importedFiles - .map(reference => reference.fileName) - .filter(specifier => specifier.startsWith('.')) - .map(specifier => { - const resolved = path.resolve(repositoryRoot, path.dirname(file), specifier); - const candidates = [resolved.replace(/\.js$/, '.ts'), resolved.replace(/\.js$/, '.tsx'), resolved.replace(/\.js$/, '.d.ts'), resolved]; - const dependency = candidates.find(candidate => fs.existsSync(candidate) && fs.statSync(candidate).isFile()); - assert.ok(dependency, `Cannot resolve ${specifier} from ${file}`); - return path.relative(repositoryRoot, dependency).replaceAll('\\', '/'); - }); - dependenciesByFile.set(file, dependencies); - } - pending.push(...dependencies); - } - return visited; -} diff --git a/build/lib/test/sourceDependencies.ts b/build/lib/test/sourceDependencies.ts new file mode 100644 index 00000000000000..b15636c9a2d2ed --- /dev/null +++ b/build/lib/test/sourceDependencies.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * 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 fs from 'fs'; +import path from 'path'; +import ts from 'typescript'; + +export const repositoryRoot = path.resolve(import.meta.dirname, '../../..'); + +/** + * Resolves the relative import `specifier` of `file`, both relative to the repository root, to the source file it loads. + */ +export function resolveSourceImport(file: string, specifier: string): string { + const resolved = path.resolve(repositoryRoot, path.dirname(file), specifier); + const candidates = [resolved.replace(/\.js$/, '.ts'), resolved.replace(/\.js$/, '.tsx'), resolved.replace(/\.js$/, '.d.ts'), resolved]; + const dependency = candidates.find(candidate => fs.existsSync(candidate) && fs.statSync(candidate).isFile()); + assert.ok(dependency, `Cannot resolve ${specifier} from ${file}`); + return path.relative(repositoryRoot, dependency).replaceAll('\\', '/'); +} + +const dependenciesByFile = new Map(); + +/** + * The source files that `entry` loads, itself included, through its relative imports. Includes type imports + * conservatively. + */ +export function collectSourceDependencies(entry: string): Set { + const visited = new Set(); + const pending = [entry]; + while (pending.length > 0) { + const file = pending.pop()!; + if (visited.has(file)) { + continue; + } + visited.add(file); + if (!file.endsWith('.ts') && !file.endsWith('.js')) { + continue; + } + let dependencies = dependenciesByFile.get(file); + if (!dependencies) { + const source = fs.readFileSync(path.join(repositoryRoot, file), 'utf8'); + dependencies = ts.preProcessFile(source, true).importedFiles + .map(reference => reference.fileName) + .filter(specifier => specifier.startsWith('.')) + .map(specifier => resolveSourceImport(file, specifier)); + dependenciesByFile.set(file, dependencies); + } + pending.push(...dependencies); + } + return visited; +} diff --git a/eslint.config.js b/eslint.config.js index 9db9923e5d76f8..2d8c65aabf8677 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2013,6 +2013,7 @@ export default [ 'vs/workbench/api/~', 'vs/workbench/services/*/~', 'vs/workbench/contrib/*/~', + 'vs/workbench/contrib/sessions/providers/*/~', // the sessions providers of editor windows (see IEditorWindowSessionsRuntime) 'vs/workbench/contrib/terminal/terminal.all.js', 'vs/sessions/sessions.core.main.js', ] diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsViewProviders.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsViewProviders.ts new file mode 100644 index 00000000000000..276467f27c7ec6 --- /dev/null +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsViewProviders.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { raceTimeout } from '../../../../../base/common/async.js'; +import { DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { autorun, observableSignal } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ISessionsManagementService } from '../../common/sessionsManagement.js'; + +export const IAgentsViewProvidersService = createDecorator('agentsViewProvidersService'); + +/** + * Holds the sessions providers of an editor window. Their sessions are known only while something holds them: what shows + * sessions while it shows (see `IEditorWindowSessionsRuntime`), or an open of a session until it shows. + */ +export interface IAgentsViewProvidersService { + readonly _serviceBrand: undefined; + + /** Registers the providers, unless they are, and holds them until the returned disposable is disposed. */ + acquire(): IDisposable; +} + +/** How long an open waits for the session it opens to be known, such as while its provider lists its sessions. */ +export const AGENTS_VIEW_SESSION_WAIT_TIMEOUT = 5_000; + +/** + * What `find` finds among the sessions and their chats, once it does, or `undefined` once `timeout` passed. `resource` is + * what it looks for, a session or a chat: a peer chat of a session is the session's resource with the chat's id as its + * fragment, so the session it may appear in is known by lookup, also outside a provider's listing scope. + */ +export async function waitForAgentsViewSession(sessionsManagementService: ISessionsManagementService, resource: URI, find: () => T | undefined, timeout = AGENTS_VIEW_SESSION_WAIT_TIMEOUT): Promise { + const found = find(); + if (found) { + return found; + } + const store = new DisposableStore(); + try { + return await raceTimeout(new Promise(resolve => { + // The sessions it knows of, by their ID: those listed, and those that change, also outside a provider's listing + // scope, such as of another folder, which an open can find + const sessions = new Map(sessionsManagementService.getSessions().map(session => [session.sessionId, session])); + const sessionsChanged = observableSignal('waitForAgentsViewSession'); + store.add(sessionsManagementService.onDidChangeSession(changes => { + for (const { session, kind } of changes) { + if (kind === 'removed') { + sessions.delete(session.sessionId); + } else { + sessions.set(session.sessionId, session); + } + } + sessionsChanged.trigger(undefined); + })); + store.add(autorun(reader => { + sessionsChanged.read(reader); + // The session of `resource`, or whose peer chat it is, which a provider can know without listing it or + // reporting a change of it as it publishes the chat + const candidates = new Map(sessions); + for (const lookup of [resource, resource.with({ fragment: '' })]) { + const session = sessionsManagementService.getSession(lookup); + if (session) { + candidates.set(session.sessionId, session); + } + } + // A session can list a chat after it is listed itself, such as a peer chat its chats publish later: only a + // session that has several chats can + for (const session of candidates.values()) { + if (session.capabilities.read(reader).supportsMultipleChats) { + session.chats.read(reader); + } + } + const value = find(); + if (value) { + resolve(value); + } + })); + }), timeout); + } finally { + store.dispose(); + } +} diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowMainSessionHost.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowMainSessionHost.ts new file mode 100644 index 00000000000000..e5f876c9f139d1 --- /dev/null +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowMainSessionHost.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { mainWindow } from '../../../../../base/browser/window.js'; +import { IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { constObservable, derived, IObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { SessionHost, SessionHostKind } from '../../../../services/sessions/browser/sessionHost.js'; +import { IChat, ISession } from '../../../../services/sessions/common/session.js'; + +/** A chat with its session, such as the one the active tab of the chat part shows. */ +export interface IEditorWindowShownChat { + readonly session: ISession; + readonly chat: IChat; +} + +/** + * The session host of an editor window, as the service `ISessionHost` of the window: it shows the chat that a feature binds + * (see {@link bindShownChat}), such as that of the active tab of the chat part. What the editors of the editor area show of + * a session, such as the Changes editor that the pills of a chat open, is of that chat. Until a feature binds one, it shows + * nothing and does nothing: it only derives what it shows from what is bound. + */ +export class EditorWindowMainSessionHost extends SessionHost { + + private readonly _shown: ISettableObservable | undefined>; + + constructor(@IContextKeyService contextKeyService: IContextKeyService) { + const source = observableValue | undefined>('editorWindowShownChat', undefined); + const shown = derived(reader => source.read(reader)?.read(reader)); + super({ + kind: SessionHostKind.MainWindow, + parent: undefined, + session: derived(reader => shown.read(reader)?.session), + chat: derived(reader => shown.read(reader)?.chat), + contextKeyService, + windowId: constObservable(mainWindow.vscodeWindowId), + }); + this._shown = source; + } + + /** Shows the chat `shown` names, until the returned disposable is disposed. */ + bindShownChat(shown: IObservable): IDisposable { + this._shown.set(shown, undefined); + return toDisposable(() => { + if (this._shown.get() === shown) { + this._shown.set(undefined, undefined); + } + }); + } +} diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsNavigationHost.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsNavigationHost.ts new file mode 100644 index 00000000000000..a6332de209d2bc --- /dev/null +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsNavigationHost.ts @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { constObservable, IObservable } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ISessionGridPane } from '../../../../services/sessions/browser/sessionGridPane.js'; +import { ISessionHost } from '../../../../services/sessions/browser/sessionHost.js'; +import { IChat, ISession } from '../../../../services/sessions/common/session.js'; +import { ISessionsManagementService } from '../../common/sessionsManagement.js'; +import { ISessionsNavigationService } from '../sessionsNavigationService.js'; +import type { IOpenChatOptions, IOpenSessionOptions } from '../sessionsService.js'; +import { IEditorWindowSessionsPresenterService } from '../editorWindow/editorWindowSessionsPresenter.js'; +import { IAgentsViewProvidersService, waitForAgentsViewSession } from './agentsViewProviders.js'; + +/** + * The navigation of an editor window: sessions and chats open where its presenter shows them (see + * `IEditorWindowSessionsPresenterService`), such as tabs of the chat part. The window has no grid of sessions: the + * presenter's active chat is its active session. + */ +export class EditorWindowSessionsNavigationHost implements ISessionsNavigationService { + + declare readonly _serviceBrand: undefined; + + readonly slotPanes: IObservable = constObservable([]); + + constructor( + @IEditorWindowSessionsPresenterService private readonly _presenterService: IEditorWindowSessionsPresenterService, + @ISessionHost private readonly _mainWindowHost: ISessionHost, + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @IAgentsViewProvidersService private readonly _providers: IAgentsViewProvidersService, + ) { } + + /** What shows the chats of sessions: the chat part, while a feature shows them there, or the Chat view. */ + private get _presenter() { + return this._presenterService.presenter.get(); + } + + get mainWindowHost(): ISessionHost { + return this._mainWindowHost; + } + + async openSession(sessionResource: URI, options?: IOpenSessionOptions): Promise { + // Its session is known only while the providers are held: until it shows, such as in the chat part, which holds them then + const hold = this._providers.acquire(); + try { + const session = await waitForAgentsViewSession(this._sessionsManagementService, sessionResource, () => this._sessionsManagementService.getSession(sessionResource)); + if (session) { + await this._presenter.openChat(session, undefined, { preserveFocus: options?.preserveFocus }); + } + } finally { + hold.dispose(); + } + } + + async openSessionToSide(session: ISession, options?: IOpenSessionOptions): Promise { + await this._presenter.openChat(session, undefined, { preserveFocus: options?.preserveFocus, sideBySide: true }); + } + + async openChat(session: ISession, chatUri: URI, options?: IOpenChatOptions): Promise { + await this._presenter.openChat(session, chatUri, { preserveFocus: options?.preserveFocus }); + } + + async openChatToSide(session: ISession, chatResource: URI, options?: { readonly preserveFocus?: boolean }): Promise { + await this._presenter.openChat(session, chatResource, { preserveFocus: options?.preserveFocus, sideBySide: true }); + } + + async canOpenSession(_session: ISession): Promise { + return true; + } + + focusActiveSession(): void { + this._presenter.focus(); + } + + async closeArchivedChat(_session: ISession, chat: IChat): Promise { + await this._presenter.closeChat(chat.resource); + } +} diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsService.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsService.ts new file mode 100644 index 00000000000000..e1743a0295a92b --- /dev/null +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsService.ts @@ -0,0 +1,291 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { Event } from '../../../../../base/common/event.js'; +import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { constObservable, IObservable, waitForState } from '../../../../../base/common/observable.js'; +import { isEqual } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IWorkspaceTrustRequestService } from '../../../../../platform/workspace/common/workspaceTrust.js'; +import { ISessionGridPane } from '../../../../services/sessions/browser/sessionGridPane.js'; +import { ISessionHost } from '../../../../services/sessions/browser/sessionHost.js'; +import { IChat, ISession } from '../../../../services/sessions/common/session.js'; +import { isAgentHostProvider } from '../../common/agentHostSessionsProvider.js'; +import { ICreateNewSessionOptions, IRecentlyOpenedSessions, ISessionsManagementService, IToggleSessionStickinessEvent } from '../../common/sessionsManagement.js'; +import { ISessionOpenTelemetryService, SESSION_OPEN_TIMEOUT_MS } from '../sessionOpenTelemetryService.js'; +import { ISessionsProvidersService } from '../sessionsProvidersService.js'; +import { ICloseChatOptions, IOpenChatOptions, IOpenNewChatInSessionOptions, IOpenNewSessionOptions, IOpenNewSessionResult, IOpenSessionOptions, IOpenSessionsOptions, ISessionNavigationRequest, ISessionsService, SessionGridDirection } from '../sessionsService.js'; +import { getEditorWindowComposerTarget, IEditorWindowSessionsPresenterService } from '../editorWindow/editorWindowSessionsPresenter.js'; +import { IAgentsViewProvidersService, waitForAgentsViewSession } from './agentsViewProviders.js'; + +/** + * The sessions of an editor window, for the UI the Agents window shares with it, such as the chat view and its pills: the + * window has no grid of sessions. Its presenter shows sessions and chats (see `IEditorWindowSessionsPresenterService`), + * such as the chat part, each in a tab, whose active tab is the active session. What only a grid does, such as moving or + * resizing panes, does nothing here. + */ +export class EditorWindowSessionsService extends Disposable implements ISessionsService { + + declare readonly _serviceBrand: undefined; + + readonly panes: IObservable = constObservable([]); + readonly slotPanes: IObservable = constObservable([]); + readonly activePane: IObservable = constObservable(undefined); + readonly initialRestoreComplete: IObservable = constObservable(true); + readonly navigationRequest: IObservable = constObservable(undefined); + readonly onDidToggleSessionStickiness: Event = Event.None; + + constructor( + @IEditorWindowSessionsPresenterService private readonly _presenterService: IEditorWindowSessionsPresenterService, + @ISessionHost private readonly _mainWindowHost: ISessionHost, + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, + @IWorkspaceTrustRequestService private readonly _workspaceTrustRequestService: IWorkspaceTrustRequestService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @ILogService private readonly _logService: ILogService, + @ISessionOpenTelemetryService private readonly _sessionOpenTelemetryService: ISessionOpenTelemetryService, + @IAgentsViewProvidersService private readonly _providers: IAgentsViewProvidersService, + @IEditorService private readonly _editorService: IEditorService, + ) { + super(); + } + + /** What shows the chats of sessions: the chat part, while a feature shows them there, or the Chat view. */ + private get _presenter() { + return this._presenterService.presenter.get(); + } + + get gridHost(): ISessionHost { + return this._mainWindowHost; + } + + //#region Panes: the window has none + + getPaneOfHost(): ISessionGridPane | undefined { + return undefined; + } + + /** The host of the presenter's active chat stands for the active pane. */ + isActivePane(host: ISessionHost): boolean { + const active = this._presenter.activeChat.get(); + return host === this.gridHost || (!!active && isEqual(host.chatResource.get(), active.chat.resource)); + } + + isDetachedPane(): boolean { + return false; + } + + activatePane(): void { } + closePane(): void { } + collapseToPane(): void { } + focusPaneInDirection(): void { } + movePaneInDirection(): void { } + resizePaneInDirection(): void { } + arrangeSessions(): void { } + toggleSessionStickiness(): void { } + closeAllSessions(): void { } + attachChat(): void { } + async detachChat(): Promise { } + async reopenLastClosedItem(): Promise { } + async restoreVisibleSessions(): Promise { } + async openPreviousSession(): Promise { } + async openNextSession(): Promise { } + /** It keeps no navigation history, so it restores no saved draft from it. */ + registerSavedDraftOpener(_opener: (draftId: string, token: CancellationToken) => Promise): IDisposable { + return Disposable.None; + } + setCurrentDraftId(_draftId: string | undefined, _preserveCurrentEntry?: boolean): void { } + + async submitNewSessionInput(): Promise { + return false; + } + + hideChatFromGrid(): IDisposable { + return Disposable.None; + } + + getRecentlyOpenedSessions(): IRecentlyOpenedSessions { + return { recent: [], other: [] }; + } + + //#endregion + + //#region Sessions and chats: shown by the presenter + + showSession(sessionResource: URI, options?: { preserveFocus?: boolean }): void { + void this.openSession(sessionResource, options); + } + + async openSession(sessionResource: URI, options?: IOpenSessionOptions): Promise { + // Its session is known only while the providers are held: until it shows, such as in the chat part, which holds them then + const hold = this._providers.acquire(); + try { + const session = await waitForAgentsViewSession(this._sessionsManagementService, sessionResource, () => this._sessionsManagementService.getSession(sessionResource)); + if (session) { + await this._openSession(session, options); + } + } finally { + hold.dispose(); + } + } + + private async _openSession(session: ISession, options: IOpenSessionOptions | undefined): Promise { + // As the Agents window reports opening a session: until its chat shows and the session loaded. The wait for the session + // to load ends just after the attempt timed out, which the telemetry service reports. + const cts = new CancellationTokenSource(); + const timeout = setTimeout(() => cts.cancel(), SESSION_OPEN_TIMEOUT_MS + 1000); + try { + await this._sessionOpenTelemetryService.withOpenRequest(options?.source ?? 'unknown', cts.token, async attempt => { + const chatResource = session.mainChat.get().resource; + this._sessionOpenTelemetryService.sessionResolved(attempt, session.resource, session.providerId, this._presenter.isShowing(chatResource), session.loading.get()); + await this._presenter.openChat(session, undefined, { preserveFocus: options?.preserveFocus }); + this._sessionOpenTelemetryService.sessionActivated(attempt, chatResource); + await waitForState(session.loading, loading => !loading, undefined, cts.token); + this._sessionOpenTelemetryService.sessionLoaded(attempt); + }); + } catch (error) { + if (!cts.token.isCancellationRequested) { + throw error; + } + } finally { + clearTimeout(timeout); + cts.dispose(); + } + } + + async openSessionToSide(session: ISession, options?: IOpenSessionOptions & { chatResource?: URI }): Promise { + await this._presenter.openChat(session, options?.chatResource, { preserveFocus: options?.preserveFocus, sideBySide: true }); + } + + async openChatToSide(session: ISession, chatResource: URI, options?: { preserveFocus?: boolean }): Promise { + await this._presenter.openChat(session, chatResource, { preserveFocus: options?.preserveFocus, sideBySide: true }); + } + + async canOpenSession(): Promise { + return true; + } + + async openChat(session: ISession, chatUri: URI, options?: IOpenChatOptions): Promise { + await this._presenter.openChat(session, chatUri, { preserveFocus: options?.preserveFocus }); + } + + async moveChatToGrid(sessionId: string, chatResource: URI, _referenceSlotId: string, _direction: SessionGridDirection): Promise { + const owner = this._sessionsManagementService.getSessionForChatResource(chatResource); + const session = owner?.session.sessionId === sessionId ? owner.session : this._sessionsManagementService.getSessions().find(candidate => candidate.sessionId === sessionId); + if (session) { + await this._presenter.openChat(session, chatResource); + } + } + + async closeChat(_session: ISession, chat: IChat, _options?: ICloseChatOptions): Promise { + await this._presenter.closeChat(chat.resource); + } + + closeSession(session: ISession | undefined): void { + for (const chat of session?.chats.get() ?? []) { + void this._presenter.closeChat(chat.resource); + } + } + + insertAt(session: ISession | undefined): void { + if (session) { + void this._presenter.openChat(session, undefined); + } + } + + async openSessionsAt(sessions: readonly ISession[], _referenceSessionId: string | undefined, _direction: SessionGridDirection, options?: IOpenSessionsOptions): Promise { + await this.openSessionsInGrid(sessions, options); + } + + async openSessionsInGrid(sessions: readonly ISession[], options?: IOpenSessionOptions): Promise { + for (const session of sessions) { + await this._presenter.openChat(session, undefined, { preserveFocus: options?.preserveFocus }); + } + } + + async openChatsInGrid(chats: readonly { readonly session: ISession; readonly chatResource: URI }[]): Promise { + for (const { session, chatResource } of chats) { + await this._presenter.openChat(session, chatResource); + } + } + + async openMovedChats(chats: readonly { readonly session: ISession; readonly chatResource: URI }[]): Promise { + await this.openChatsInGrid(chats); + } + + focusActiveSession(): void { + this._presenter.focus(); + } + + //#endregion + + //#region New sessions and chats: composers of the presenter + + async openNewSession(options?: IOpenNewSessionOptions, token: CancellationToken = CancellationToken.None): Promise { + // A composer that holds its drafts in a slot of its own, such as one of the chat part, creates its draft there + const draftSlot = options?.draftSlot && options.draftSlot !== this._sessionsManagementService.defaultNewSessionSlot ? options.draftSlot : undefined; + if (!draftSlot) { + const target = options?.folderUri + ? { folderUri: options.folderUri, providerId: options.providerId, sessionTypeId: options.sessionTypeId } + : getEditorWindowComposerTarget(this._workspaceContextService, this._editorService); + await this._presenter.openComposer(target, { sideBySide: options?.toSide }); + return { session: undefined, trustDeclined: false }; + } + + const folderUri = options?.folderUri; + if (folderUri) { + const resolved = this._sessionsManagementService.resolveWorkspace(folderUri, options?.providerId); + if (resolved?.workspace.requiresWorkspaceTrust) { + const trusted = await this._workspaceTrustRequestService.requestResourcesTrust({ + uri: folderUri, + message: localize('editorWindowSessions.trustFolderMessage', "An agent session will be able to read files, run commands, and make changes in this folder."), + remoteHostLabel: this._sessionsManagementService.getRemoteHostLabel(resolved.workspace, resolved.providerId), + }); + if (token.isCancellationRequested || !trusted) { + return { session: undefined, trustDeclined: !token.isCancellationRequested && !trusted }; + } + } + try { + const session = draftSlot.createNewSession(folderUri, options); + if (options?.requireDevContainer) { + const provider = this._sessionsProvidersService.getProvider(session.providerId, session.sessionId); + if (!provider || !isAgentHostProvider(provider) || !provider.preferDevContainer) { + draftSlot.discardNewSession(session); + throw new Error(`Session provider '${session.providerId}' does not support Dev Container drafts.`); + } + provider.preferDevContainer(session.sessionId, { required: true }); + } + return { session, trustDeclined: false }; + } catch (error) { + this._logService.trace(`[AgentsView] openNewSession: could not create a draft for ${folderUri.toString()}`, error); + } + } + return { session: draftSlot.newSession.get(), trustDeclined: false }; + } + + openQuickChat(_options?: ICreateNewSessionOptions): ISession | undefined { + void this._presenter.openComposer(undefined); + return undefined; + } + + async openNewChatInSession(session: ISession, options?: IOpenNewChatInSessionOptions): Promise { + const { from: _from, ...createOptions } = options ?? {}; + const chat = await this._sessionsManagementService.createNewChatInSession(session, Object.keys(createOptions).length ? createOptions : undefined); + if (chat) { + await this._presenter.openChat(session, chat.resource); + } + } + + unsetNewSession(): void { + this._sessionsManagementService.discardNewSession(); + } + + //#endregion +} diff --git a/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessions.contribution.ts b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessions.contribution.ts index 4fd1fe3fe4455b..d797da4bdfe5f3 100644 --- a/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessions.contribution.ts +++ b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessions.contribution.ts @@ -40,6 +40,32 @@ import { ISessionActivityBarActivityService, SessionActivityBarActivityService } import { IDiffEditorOptionsService } from '../../../../services/editor/common/diffEditorOptionsService.js'; import { DiffEditorOptionsService } from '../../../../services/editor/browser/diffEditorOptionsService.js'; import { EditorWindowSessionsHostLayout } from './editorWindowSessionsHostLayout.js'; +import { IFocusedSessionHostService, ISessionHost, ISessionHostService } from '../../../../services/sessions/browser/sessionHost.js'; +import { GitHubService, IGitHubService } from '../github/githubService.js'; +import '../sessionAttentionService.js'; +import { FocusedSessionHostService, SessionHostService } from '../sessionHostService.js'; +import { ISessionWorktreeCleanupService, SessionWorktreeCleanupService } from '../sessionInputBanners/sessionWorktreeCleanupService.js'; +import { ISessionsListModelService, SessionsListModelService } from '../sessionsListModelService.js'; +import { ISessionDraftsService, SessionDraftsService } from '../sessionDraftsService.js'; +import { IScheduledChatsService, ScheduledChatsService } from '../scheduledChatsService.js'; +import { IChatWindowService } from '../../../chatWindow/browser/chatWindow.js'; +import { NullChatWindowService } from '../../../chatWindow/browser/nullChatWindowService.js'; +import { EditorWindowMainSessionHost } from '../agentsView/editorWindowMainSessionHost.js'; +import { IChatViewFactory } from '../chatViewFactory.js'; +import { DefaultChatViewFactory } from '../chat/defaultChatViewFactory.js'; +import { INewChatVoiceTargetService, NewChatVoiceTargetService } from '../chat/newChatVoice.js'; +import { ISessionChatPillsDebugService, SessionChatPillsDebugService } from '../chat/sessionChatInputToolbarDebug.js'; +import { ISessionArchiveNudgeService, SessionArchiveNudgeService } from '../chat/sessionArchiveNudge.js'; +import { IWorktreeSuggestionService, WorktreeSuggestionService } from '../worktreeSuggestion/worktreeSuggestionService.js'; +import { ISessionChangesService, SessionChangesService } from '../../../sessionChanges/browser/sessionChangesService.js'; +import { ChangesViewService } from '../../../sessionChanges/browser/changesViewService.js'; +import { IChangesViewService } from '../../common/changesViewService.js'; +import { ISessionsService } from '../sessionsService.js'; +import { ISessionsNavigationService } from '../sessionsNavigationService.js'; +import { EditorWindowSessionsService } from '../agentsView/editorWindowSessionsService.js'; +import { EditorWindowSessionsNavigationHost } from '../agentsView/editorWindowSessionsNavigationHost.js'; +import { EditorWindowSessionsPresenterService, IEditorWindowSessionsPresenterService } from './editorWindowSessionsPresenter.js'; +import { EditorWindowSessionsRuntime, IEditorWindowSessionsRuntime } from './editorWindowSessionsRuntime.js'; // The sessions services of an editor window, for the sessions features it can host. Every one is created only as it is // first used, and nothing in the window uses them until such a feature does. @@ -70,3 +96,31 @@ registerSingleton(ISessionsChatBackgroundService, SessionsChatBackgroundService, registerSingleton(INewSessionComposerService, NewSessionComposerService, InstantiationType.Delayed); registerSingleton(ISessionActivityBarActivityService, SessionActivityBarActivityService, InstantiationType.Delayed); registerSingleton(IDiffEditorOptionsService, DiffEditorOptionsService, InstantiationType.Delayed); + +// The sessions UI that a feature hosts in an editor window, such as a sessions list or the chat part: its hosts, its +// composers' drafts and scheduled requests, and the changes of its sessions, as the Agents window has them +registerSingleton(ISessionsListModelService, SessionsListModelService, InstantiationType.Delayed); +registerSingleton(ISessionHostService, SessionHostService, InstantiationType.Delayed); +registerSingleton(IFocusedSessionHostService, FocusedSessionHostService, InstantiationType.Delayed); +registerSingleton(IGitHubService, GitHubService, InstantiationType.Delayed); +registerSingleton(ISessionWorktreeCleanupService, SessionWorktreeCleanupService, InstantiationType.Delayed); +registerSingleton(ISessionDraftsService, SessionDraftsService, InstantiationType.Delayed); +registerSingleton(IScheduledChatsService, ScheduledChatsService, InstantiationType.Delayed); +// Editor windows open no separate chat windows: the chats' input toolbars, which inject it, find none +registerSingleton(IChatWindowService, NullChatWindowService, InstantiationType.Delayed); +// Not delayed: hosts are compared by identity, which a delayed proxy would not keep. It does nothing until a feature binds what it shows +registerSingleton(ISessionHost, EditorWindowMainSessionHost, InstantiationType.Eager); +// As the Agents window's, so that its chats show over the chat background the user picked +registerSingleton(IChatViewFactory, DefaultChatViewFactory, InstantiationType.Delayed); +registerSingleton(INewChatVoiceTargetService, NewChatVoiceTargetService, InstantiationType.Delayed); +registerSingleton(ISessionChatPillsDebugService, SessionChatPillsDebugService, InstantiationType.Delayed); +registerSingleton(ISessionArchiveNudgeService, SessionArchiveNudgeService, InstantiationType.Delayed); +registerSingleton(IWorktreeSuggestionService, WorktreeSuggestionService, InstantiationType.Delayed); +registerSingleton(ISessionChangesService, SessionChangesService, InstantiationType.Delayed); +registerSingleton(IChangesViewService, ChangesViewService, InstantiationType.Delayed); +// Its sessions and their chats, which its presenter shows: the chat part, while a feature shows them there, or the Chat view +registerSingleton(ISessionsService, EditorWindowSessionsService, InstantiationType.Delayed); +registerSingleton(ISessionsNavigationService, EditorWindowSessionsNavigationHost, InstantiationType.Delayed); +registerSingleton(IEditorWindowSessionsPresenterService, EditorWindowSessionsPresenterService, InstantiationType.Delayed); +// Holds the sessions providers for the features that enable it, while what they show shows +registerSingleton(IEditorWindowSessionsRuntime, EditorWindowSessionsRuntime, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsPresenter.ts b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsPresenter.ts new file mode 100644 index 00000000000000..8138764087cef9 --- /dev/null +++ b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsPresenter.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { constObservable, derived, IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { EditorResourceAccessor, SideBySideEditor } from '../../../../common/editor.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IChat, ISession } from '../../../../services/sessions/common/session.js'; +import { IChatViewHostService } from '../../../chat/browser/chatViewHost.js'; +import { INewSessionTarget } from '../parts/chatView.js'; + +/** A chat that a presenter shows, with its session. */ +export interface IEditorWindowPresentedChat { + readonly session: ISession; + readonly chat: IChat; +} + +export interface IEditorWindowPresenterOpenOptions { + readonly preserveFocus?: boolean; + /** Opens beside the active chat, in a group of its own. */ + readonly sideBySide?: boolean; + /** Opens a new composer, also while the active one has not sent its session yet. */ + readonly newComposer?: boolean; +} + +/** + * What shows the chats of sessions in an editor window, for the sessions services that the Agents window's UI injects there + * (see `EditorWindowSessionsService`): the chat part, while a feature shows the chats there, or else the Chat view. + */ +export interface IEditorWindowSessionsPresenter { + /** The chat it shows as the window's active chat, with its session. */ + readonly activeChat: IObservable; + + /** Shows `chatResource` of `session`, or its main chat. */ + openChat(session: ISession, chatResource: URI | undefined, options?: IEditorWindowPresenterOpenOptions): Promise; + + /** Opens a new-session composer aimed at `target`. */ + openComposer(target: INewSessionTarget | undefined, options?: IEditorWindowPresenterOpenOptions): Promise; + + /** Stops showing `chatResource`, where it shows it. */ + closeChat(chatResource: URI): Promise; + + /** Whether it shows `chatResource`. */ + isShowing(chatResource: URI): boolean; + + /** Whether it shows a new-session composer that has not sent its session yet, whose draft is a provider's. */ + hasComposer(): boolean; + + /** Closes the new-session composers with nothing typed or attached, and no send under way. */ + closeIdleComposers(): Promise; + + /** Moves focus into what shows the active chat. */ + focus(): void; +} + +export const IEditorWindowSessionsPresenterService = createDecorator('editorWindowSessionsPresenterService'); + +/** The presenter of an editor window's sessions: the one registered last, or else the Chat view. */ +export interface IEditorWindowSessionsPresenterService { + readonly _serviceBrand: undefined; + + readonly presenter: IObservable; + + /** Shows the chats of sessions with `presenter` until the returned disposable is disposed; the one before shows them then. */ + registerPresenter(presenter: IEditorWindowSessionsPresenter): IDisposable; +} + +/** The Chat view, whose host shows one chat at a time, as presenter of the sessions of an editor window. */ +class ChatViewSessionsPresenter implements IEditorWindowSessionsPresenter { + + readonly activeChat: IObservable = constObservable(undefined); + + constructor(private readonly _chatViewHostService: IChatViewHostService) { } + + async openChat(session: ISession, chatResource: URI | undefined, options?: IEditorWindowPresenterOpenOptions): Promise { + await this._chatViewHostService.openSession(chatResource ?? session.mainChat.get().resource, { preserveFocus: options?.preserveFocus }); + } + + async openComposer(_target: INewSessionTarget | undefined, options?: IEditorWindowPresenterOpenOptions): Promise { + await this._chatViewHostService.reveal(options?.preserveFocus); + } + + async closeChat(): Promise { } + + isShowing(): boolean { + return false; + } + + hasComposer(): boolean { + return false; + } + + async closeIdleComposers(): Promise { } + + focus(): void { + void this._chatViewHostService.reveal(false); + } +} + +export class EditorWindowSessionsPresenterService extends Disposable implements IEditorWindowSessionsPresenterService { + + declare readonly _serviceBrand: undefined; + + private readonly _registered = observableValue(this, []); + readonly presenter: IObservable; + + constructor(@IChatViewHostService chatViewHostService: IChatViewHostService) { + super(); + const chatView = new ChatViewSessionsPresenter(chatViewHostService); + this.presenter = derived(this, reader => this._registered.read(reader).at(-1) ?? chatView); + } + + registerPresenter(presenter: IEditorWindowSessionsPresenter): IDisposable { + this._registered.set([...this._registered.get(), presenter], undefined); + return toDisposable(() => this._registered.set(this._registered.get().filter(candidate => candidate !== presenter), undefined)); + } +} + +/** Where a new-session composer of an editor window aims: the folder of the active editor, or else the window's first folder. */ +export function getEditorWindowComposerTarget(workspaceContextService: IWorkspaceContextService, editorService: IEditorService): INewSessionTarget | undefined { + const activeResource = EditorResourceAccessor.getOriginalUri(editorService.activeEditor, { supportSideBySide: SideBySideEditor.PRIMARY }); + const folder = (activeResource && workspaceContextService.getWorkspaceFolder(activeResource)) ?? workspaceContextService.getWorkspace().folders.at(0); + return folder ? { folderUri: folder.uri } : undefined; +} diff --git a/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsRuntime.ts b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsRuntime.ts new file mode 100644 index 00000000000000..8508b002e8f0e5 --- /dev/null +++ b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsRuntime.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { derived, IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchLayoutService, Parts } from '../../../../services/layout/browser/layoutService.js'; +import { ILifecycleService, LifecyclePhase } from '../../../../services/lifecycle/common/lifecycle.js'; +import { IViewsService } from '../../../../services/views/common/viewsService.js'; +import { IAgentsViewProvidersService } from '../agentsView/agentsViewProviders.js'; + +export interface IEditorWindowSessionsRuntimeOptions { + /** The views that show sessions, such as a sessions list: while one of them shows, or the chat part does, the sessions are known. */ + readonly viewIds: readonly string[]; +} + +export const IEditorWindowSessionsRuntime = createDecorator('editorWindowSessionsRuntime'); + +/** + * The sessions of an editor window, for the features that show them there, such as a sessions list or the chat part: it + * holds the sessions providers of the window (see `IAgentsViewProvidersService`) while what shows sessions shows. It does + * nothing until a feature enables it. + */ +export interface IEditorWindowSessionsRuntime { + readonly _serviceBrand: undefined; + + /** Whether a feature enabled it. */ + readonly enabled: IObservable; + + /** + * Holds the sessions providers while one of `options.viewIds`, or the chat part, shows, until the returned disposable is + * disposed. Features that enable it together share the providers: they are held while what any of them shows shows. + */ + enable(options: IEditorWindowSessionsRuntimeOptions): IDisposable; +} + +export class EditorWindowSessionsRuntime extends Disposable implements IEditorWindowSessionsRuntime { + + declare readonly _serviceBrand: undefined; + + private readonly _owners = observableValue(this, []); + readonly enabled: IObservable = derived(this, reader => this._owners.read(reader).length > 0); + + private readonly _hold = this._register(new MutableDisposable()); + /** What it listens to, from when a feature first enables it. */ + private readonly _listeners = this._register(new MutableDisposable()); + + constructor( + @IViewsService private readonly _viewsService: IViewsService, + @IWorkbenchLayoutService private readonly _layoutService: IWorkbenchLayoutService, + @ILifecycleService private readonly _lifecycleService: ILifecycleService, + @IAgentsViewProvidersService private readonly _providers: IAgentsViewProvidersService, + ) { + super(); + } + + enable(options: IEditorWindowSessionsRuntimeOptions): IDisposable { + this._owners.set([...this._owners.get(), options], undefined); + this._listen(); + this._update(); + return toDisposable(() => { + this._owners.set(this._owners.get().filter(candidate => candidate !== options), undefined); + if (!this._owners.get().length) { + this._listeners.clear(); + } + this._update(); + }); + } + + private _listen(): void { + if (this._listeners.value) { + return; + } + const listeners = this._listeners.value = new DisposableStore(); + listeners.add(this._viewsService.onDidChangeViewVisibility(e => { + if (this._owners.get().some(owner => owner.viewIds.includes(e.id))) { + this._update(); + } + })); + listeners.add(this._layoutService.onDidChangePartVisibility(e => { + if (e.partId === Parts.CHAT_PART) { + this._update(); + } + })); + // The chat part restores shown with the window, without a change of its visibility + void this._lifecycleService.when(LifecyclePhase.Restored).then(() => { + if (!listeners.isDisposed) { + this._update(); + } + }); + } + + private _update(): void { + if (this._store.isDisposed) { + return; + } + const owners = this._owners.get(); + const shown = owners.length > 0 && (this._layoutService.isVisible(Parts.CHAT_PART) || owners.some(owner => owner.viewIds.some(id => this._viewsService.isViewVisible(id)))); + if (shown) { + this._hold.value ??= this._providers.acquire(); + } else { + this._hold.clear(); + } + } +} diff --git a/src/vs/workbench/contrib/sessions/providers/agentsView/browser/agentsViewComposerControls.ts b/src/vs/workbench/contrib/sessions/providers/agentsView/browser/agentsViewComposerControls.ts new file mode 100644 index 00000000000000..2d8ae7259c8aef --- /dev/null +++ b/src/vs/workbench/contrib/sessions/providers/agentsView/browser/agentsViewComposerControls.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableStore, IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { ModelPickerToolbarsContribution, registerModelPickerAction } from '../../../browser/chat/modelPicker.js'; +import { registerSharedChatInputComposerToolbar, SharedChatInputComposerContribution } from '../../../browser/chat/sharedChatInputComposerToolbar.js'; +import { AgentHostAgentPickerContribution, registerAgentHostAgentPickerActions } from '../../agentHost/browser/agentHostAgentPicker.js'; +import { AgentHostSessionConfigPickerContribution, registerAgentHostSessionConfigPickerActions } from '../../agentHost/browser/agentHostSessionConfigPicker.js'; +import { CopilotPickerActionViewItemContribution, registerCopilotSandboxPickerActions } from '../../copilotChatSessions/browser/copilotSandboxPickers.js'; + +/** + * Registers the controls of the new-session composers and of the session chats of an editor window's chat part, as the + * Agents window has them, until the returned disposable is disposed: the composer's Add Context, model, agent, mode and + * approvals pickers, its repository controls, and the pickers of a running session's chat input. + */ +export function registerAgentsViewComposerControls(accessor: ServicesAccessor): IDisposable { + const instantiationService = accessor.get(IInstantiationService); + const store = new DisposableStore(); + store.add(registerSharedChatInputComposerToolbar()); + store.add(registerModelPickerAction()); + store.add(registerAgentHostAgentPickerActions()); + store.add(registerAgentHostSessionConfigPickerActions()); + store.add(registerCopilotSandboxPickerActions()); + store.add(instantiationService.createInstance(SharedChatInputComposerContribution)); + store.add(instantiationService.createInstance(ModelPickerToolbarsContribution)); + store.add(instantiationService.createInstance(AgentHostAgentPickerContribution)); + store.add(instantiationService.createInstance(AgentHostSessionConfigPickerContribution)); + store.add(instantiationService.createInstance(CopilotPickerActionViewItemContribution)); + return store; +} diff --git a/src/vs/workbench/contrib/sessions/providers/agentsView/browser/agentsViewProviders.contribution.ts b/src/vs/workbench/contrib/sessions/providers/agentsView/browser/agentsViewProviders.contribution.ts new file mode 100644 index 00000000000000..66e69626c11e03 --- /dev/null +++ b/src/vs/workbench/contrib/sessions/providers/agentsView/browser/agentsViewProviders.contribution.ts @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { disposableTimeout } from '../../../../../../base/common/async.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { isWeb } from '../../../../../../base/common/platform.js'; +import { InstantiationType, registerSingleton } from '../../../../../../platform/instantiation/common/extensions.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IDevContainerAgentHostService } from '../../../common/devContainerAgentHostService.js'; +import { IEditorWindowSessionsPresenterService } from '../../../browser/editorWindow/editorWindowSessionsPresenter.js'; +import { IAgentsViewProvidersService } from '../../../browser/agentsView/agentsViewProviders.js'; +import { registerLocalAgentHostSessionsProvider } from '../../agentHost/browser/localAgentHostSessionsProviderRegistration.js'; +import { registerCopilotChatSessionsProviders } from '../../copilotChatSessions/browser/copilotChatSessionsProvidersRegistration.js'; +import { registerAgentsViewComposerControls } from './agentsViewComposerControls.js'; +import { DevContainerAgentHostService } from '../../remoteAgentHost/browser/devContainerAgentHostService.js'; + +/** + * Registers the sessions providers whose sessions an editor window shows, such as in a sessions list or the chat part: those of the local agent + * host, on desktop, and those of Copilot Cloud. As every provider of the window, they list only the sessions of its + * folders (see `EditorWindowSessionsProvidersService`). The pickers of the chat part's composers and session chats, + * which act on the sessions of these providers, come and go with them. + */ +function registerAgentsViewProviders(accessor: ServicesAccessor): IDisposable { + const instantiationService = accessor.get(IInstantiationService); + const disposables = new DisposableStore(); + disposables.add(instantiationService.invokeFunction(registerAgentsViewComposerControls)); + if (!isWeb) { + disposables.add(instantiationService.invokeFunction(registerLocalAgentHostSessionsProvider)); + } + disposables.add(instantiationService.invokeFunction(registerCopilotChatSessionsProviders)); + return disposables; +} + +/** How long the providers stay registered once nothing holds them, such as after neither a sessions list nor the chat part shows. */ +export const AGENTS_VIEW_PROVIDERS_RELEASE_DELAY = 60_000; + +/** + * Registers the sessions providers of an editor window while something holds them (see `IEditorWindowSessionsRuntime`), + * and releases them {@link AGENTS_VIEW_PROVIDERS_RELEASE_DELAY} after nothing does, so the window stops following the + * sessions of the agent host. The chats of the chat part's tabs find their sessions again once the providers return, as a provider going away + * hides its sessions without removing them. + * + * A new-session composer of the chat part holds them too, as its draft session is its provider's: one with nothing typed or + * attached closes as they are released, and one with input holds them until it is sent or closed. + */ +export class AgentsViewProvidersService extends Disposable implements IAgentsViewProvidersService { + + declare readonly _serviceBrand: undefined; + + private _holds = 0; + private readonly _registration = this._register(new MutableDisposable()); + private readonly _release = this._register(new MutableDisposable()); + + constructor( + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IEditorWindowSessionsPresenterService private readonly _presenterService: IEditorWindowSessionsPresenterService, + ) { + super(); + } + + acquire(): IDisposable { + this._holds++; + this._release.clear(); + this._registration.value ??= this._instantiationService.invokeFunction(registerAgentsViewProviders); + let held = true; + return toDisposable(() => { + if (held) { + held = false; + if (--this._holds === 0 && !this._store.isDisposed) { + this._releaseLater(); + } + } + }); + } + + private _releaseLater(): void { + this._release.value = disposableTimeout(() => { + this._release.clear(); + void this._releaseUnheld(); + }, AGENTS_VIEW_PROVIDERS_RELEASE_DELAY); + } + + private async _releaseUnheld(): Promise { + if (this._holds > 0) { + return; + } + // A composer the user left without typing anything goes, so the providers can go too + await this._presenterService.presenter.get().closeIdleComposers(); + if (this._store.isDisposed || this._holds > 0 || this._release.value) { + return; + } + if (this._presenterService.presenter.get().hasComposer()) { + this._releaseLater(); + } else { + this._registration.clear(); + } + } +} + +registerSingleton(IDevContainerAgentHostService, DevContainerAgentHostService, InstantiationType.Delayed); +registerSingleton(IAgentsViewProvidersService, AgentsViewProvidersService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/sessions/providers/agentsView/test/browser/agentsViewProviders.contribution.test.ts b/src/vs/workbench/contrib/sessions/providers/agentsView/test/browser/agentsViewProviders.contribution.test.ts new file mode 100644 index 00000000000000..fe570aad37c6c3 --- /dev/null +++ b/src/vs/workbench/contrib/sessions/providers/agentsView/test/browser/agentsViewProviders.contribution.test.ts @@ -0,0 +1,116 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { constObservable } from '../../../../../../../base/common/observable.js'; +import { IDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { isWeb } from '../../../../../../../base/common/platform.js'; +import { upcastPartial } from '../../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../../../platform/instantiation/common/instantiation.js'; +import { AGENTS_VIEW_PROVIDERS_RELEASE_DELAY, AgentsViewProvidersService } from '../../browser/agentsViewProviders.contribution.js'; +import { IEditorWindowSessionsPresenter, IEditorWindowSessionsPresenterService } from '../../../../browser/editorWindow/editorWindowSessionsPresenter.js'; +import { timeout } from '../../../../../../../base/common/async.js'; +import { runWithFakedTimers } from '../../../../../../../base/test/common/virtualScheduling/index.js'; + +suite('AgentsViewProvidersService', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + // The composer controls come and go with the providers whose sessions they act on + const providers = isWeb ? ['registerAgentsViewComposerControls', 'registerCopilotChatSessionsProviders'] : ['registerAgentsViewComposerControls', 'registerLocalAgentHostSessionsProvider', 'registerCopilotChatSessionsProviders']; + + function createService(composer: { open: boolean; idle: boolean }, events: string[]) { + // Records the providers registered and released, instead of starting them + const providersInstantiationService = upcastPartial({ + invokeFunction: (fn: (accessor: ServicesAccessor, ...args: TS) => R, ..._args: TS): R => { + events.push(`register ${fn.name}`); + return upcastPartial({ dispose: () => { events.push(`release ${fn.name}`); } }) as R; + }, + }); + const instantiationService = upcastPartial({ + invokeFunction: (fn: (accessor: ServicesAccessor, ...args: TS) => R, ...args: TS): R => fn(upcastPartial({ get: (): T => providersInstantiationService as T }), ...args), + }); + const presenter = upcastPartial({ + hasComposer: () => composer.open, + // Closing an editor is asynchronous, as its group confirms it first + closeIdleComposers: async () => { + if (composer.open && composer.idle) { + await timeout(10); + composer.open = false; + events.push('close idle composer'); + } + }, + }); + return disposables.add(new AgentsViewProvidersService(instantiationService, upcastPartial({ presenter: constObservable(presenter) }))); + } + + test('registers the providers while held, and releases them a while after nothing holds them', () => runWithFakedTimers({}, async () => { + const events: string[] = []; + const service = createService({ open: false, idle: false }, events); + const step = async (label: string, action: () => void | Promise) => { events.push(`-- ${label}`); await action(); }; + + let first: IDisposable | undefined; + let second: IDisposable | undefined; + await step('held twice', () => { first = service.acquire(); second = service.acquire(); }); + await step('one hold ends, the delay passes', async () => { first!.dispose(); await timeout(AGENTS_VIEW_PROVIDERS_RELEASE_DELAY * 2); }); + await step('the other ends, almost the delay', async () => { second!.dispose(); await timeout(AGENTS_VIEW_PROVIDERS_RELEASE_DELAY - 1); }); + await step('held again within the delay, then not', async () => { service.acquire().dispose(); await timeout(AGENTS_VIEW_PROVIDERS_RELEASE_DELAY - 1); }); + await step('the delay passes', () => timeout(1)); + await step('held again', () => { first = service.acquire(); }); + first!.dispose(); + + assert.deepStrictEqual(events, [ + '-- held twice', ...providers.map(name => `register ${name}`), + '-- one hold ends, the delay passes', + '-- the other ends, almost the delay', + '-- held again within the delay, then not', + '-- the delay passes', ...providers.map(name => `release ${name}`), + '-- held again', ...providers.map(name => `register ${name}`), + ]); + })); + + test('closes a new-session composer with nothing typed as the delay passes, and releases the providers then', () => runWithFakedTimers({}, async () => { + const events: string[] = []; + const service = createService({ open: true, idle: true }, events); + + service.acquire().dispose(); + // Released as the composer closes, not a delay later + await timeout(AGENTS_VIEW_PROVIDERS_RELEASE_DELAY + 20); + + assert.deepStrictEqual(events, [...providers.map(name => `register ${name}`), 'close idle composer', ...providers.map(name => `release ${name}`)]); + })); + + test('held again while it closes an idle composer, it keeps the providers', () => runWithFakedTimers({}, async () => { + const events: string[] = []; + const service = createService({ open: true, idle: true }, events); + + service.acquire().dispose(); + // The delay passes and the composer starts closing, which takes a while + await timeout(AGENTS_VIEW_PROVIDERS_RELEASE_DELAY + 1); + const hold = service.acquire(); + await timeout(AGENTS_VIEW_PROVIDERS_RELEASE_DELAY); + const whileHeld = [...events]; + hold.dispose(); + + assert.deepStrictEqual(whileHeld, [...providers.map(name => `register ${name}`), 'close idle composer']); + })); + + test('a new-session composer with input holds the providers until it is gone, as its draft is their session', () => runWithFakedTimers({}, async () => { + const events: string[] = []; + const composer = { open: true, idle: false }; + const service = createService(composer, events); + + service.acquire().dispose(); + await timeout(AGENTS_VIEW_PROVIDERS_RELEASE_DELAY * 3); + const whileComposer = [...events]; + composer.open = false; + await timeout(AGENTS_VIEW_PROVIDERS_RELEASE_DELAY + 10); + + assert.deepStrictEqual({ whileComposer, after: events.slice(whileComposer.length) }, { + whileComposer: providers.map(name => `register ${name}`), + after: providers.map(name => `release ${name}`), + }); + })); +}); diff --git a/src/vs/workbench/contrib/sessions/providers/agentsView/test/browser/agentsViewServices.test.ts b/src/vs/workbench/contrib/sessions/providers/agentsView/test/browser/agentsViewServices.test.ts new file mode 100644 index 00000000000000..e6e9d04d833b65 --- /dev/null +++ b/src/vs/workbench/contrib/sessions/providers/agentsView/test/browser/agentsViewServices.test.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { getSingletonServiceDescriptors } from '../../../../../../../platform/instantiation/common/extensions.js'; +import { _util, ServiceIdentifier } from '../../../../../../../platform/instantiation/common/instantiation.js'; +import { IContextKeyService } from '../../../../../../../platform/contextkey/common/contextkey.js'; +import { EditorWindowMainSessionHost } from '../../../../browser/agentsView/editorWindowMainSessionHost.js'; +import { ChatView, NewChatView } from '../../../../browser/chat/chatView.js'; +import { NewChatInSessionWidget } from '../../../../browser/chat/newChatInSessionWidget.js'; +import { NewChatWidget } from '../../../../browser/chat/newChatWidget.js'; +import { SharedChatInputComposer } from '../../../../browser/chat/sharedChatInputComposer.js'; +import { ChatEditorView } from '../../../../browser/chatEditor/chatEditor.js'; +import { ChatEditorContent } from '../../../../browser/chatEditor/chatEditorContent.js'; +import { ChatGroupView } from '../../../../browser/parts/chatGroupView.js'; +import { SessionsList } from '../../../../browser/views/sessionsList.js'; +import { SessionsView } from '../../../../browser/views/sessionsView.js'; +import { SessionArchiveNudge } from '../../../../browser/chat/sessionArchiveNudge.js'; +import { SessionChatInputToolbar } from '../../../../browser/chat/sessionChatInputToolbar.js'; +import { SessionInputBanners } from '../../../../browser/sessionInputBanners/sessionInputBanners.js'; +import { SessionStatusIcon } from '../../../../browser/sessionStatusIcon.js'; +import { ChatCompositeBar } from '../../../../browser/parts/chatCompositeBar.js'; +import { ChatHeader } from '../../../../browser/parts/chatHeader.js'; +import { RemoteHostUnavailableEmptyState } from '../../../../browser/parts/remoteHostUnavailableEmptyState.js'; +import { ChatViewSessionActivityBar } from '../../../../browser/parts/sessionActivityBar.js'; +import { SessionHeader } from '../../../../browser/parts/sessionHeader.js'; +import { SessionReadOnlyBanner } from '../../../../browser/parts/sessionReadOnlyBanner.js'; +import { SessionRemoteConnection } from '../../../../browser/parts/sessionRemoteConnection.js'; +import { SessionsInboxScope } from '../../../../browser/views/sessionsInboxScope.js'; +import { SessionsListFilters } from '../../../../browser/views/sessionsListFilters.js'; +import { SessionsListNotification } from '../../../../browser/views/sessionsListNotification.js'; +import { SessionStorageCleanupNotice } from '../../../../browser/views/sessionStorageCleanupNotice.js'; +import { ResponseSelectionSideChatController } from '../../../../../chat/browser/responseSelectionSideChatController.js'; +// What an editor window registers for its sessions: the shared editor-window services and the Agents view's +import '../../../../browser/editorWindow/editorWindowSessions.contribution.js'; +import '../../browser/agentsViewProviders.contribution.js'; + +/** + * The services that an editor window provides without registering them here: those of the workbench's main service + * collection, and those that core modules register, which this test does not load. + */ +const CORE_SERVICES = new Set([ + 'accessibilityService', 'accessibilitySignalService', 'accountPolicyGateService', 'actionViewItemService', 'agentHostConnectionsService', + 'agentHostEnablementService', 'agentHostService', 'agentPluginService', 'agentSessions', 'chatAgentNameService', 'chatAgentService', 'chatAttachmentResolveService', + 'chatEditingService', 'chatEntitlementService', 'chatModeService', 'chatPetService', 'chatService', 'chatSessionsService', 'chatSideChatService', + 'chatSlashCommandService', 'chatSubmitRequestHandlerService', 'chatTipService', 'chatViewHostService', 'chatWidgetHistoryService', + 'chatWidgetService', 'clipboardService', 'commandService', 'configurationService', 'contextKeyService', 'contextMenuService', 'contextService', + 'contextViewService', 'defaultAccountService', 'dialogService', 'editorGroupsService', 'editorService', 'environmentService', 'extensionService', 'externalUriOpenerService', + 'fileDialogService', 'fileService', 'IFileDecorationsService', 'hostService', 'hoverService', 'IAuthenticationService', 'IChatService', 'IChatWidgetHistoryService', + 'ILanguageModelsService', 'ILanguageModelToolsService', 'instantiationService', 'IPromptsService', 'IUriIdentityService', 'IUserDataProfileService', 'IUserDataProfilesService', 'keybindingService', + 'labelService', 'languageModelsService', 'languageModelToolsService', 'layoutService', 'lifecycleService', 'listService', 'loggerService', 'logService', + 'managedSettingsService', 'menuService', 'modelService', 'notificationService', 'onboardingScenarioService', 'openerService', 'paneCompositePartService', 'pathService', 'productService', 'progressService', + 'quickInputService', 'remoteAgentHostService', 'remoteAgentService', 'requestService', 'sessionChatPillVisibilityService', 'speechService', 'storageService', 'telemetryService', 'textModelService', + 'themeService', 'uriIdentityService', 'viewDescriptorService', 'viewsService', 'voiceClientService', 'workingCopyService', + 'workspaceContextService', 'workspacesService', 'workspaceTrustManagementService', 'workspaceTrustRequestService', +]); + +suite('Editor window sessions services', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('an editor window registers every sessions service that its sessions list and chats depend on', () => { + const registered = new Map(getSingletonServiceDescriptors().map(([id, descriptor]) => [id.toString(), descriptor])); + const missing = new Set(); + const seen = new Set(); + const visit = (ctor: unknown) => { + if (!ctor || seen.has(ctor)) { + return; + } + seen.add(ctor); + for (const { id } of _util.getServiceDependencies(ctor as _util.DI_TARGET_OBJ)) { + const name = (id as ServiceIdentifier).toString(); + const descriptor = registered.get(name); + if (descriptor) { + visit(descriptor.ctor); + } else if (!CORE_SERVICES.has(name)) { + missing.add(name); + } + } + }; + // The views, and what they create as they show a session, such as a chat's input toolbar: their services are checked + // as they are created, not as the views that create them are + const roots = [ + SessionsView, SessionsList, ChatView, ChatGroupView, ChatEditorView, ChatEditorContent, NewChatWidget, NewChatInSessionWidget, SharedChatInputComposer, + NewChatView, SessionChatInputToolbar, SessionInputBanners, SessionArchiveNudge, SessionStatusIcon, ResponseSelectionSideChatController, + SessionHeader, ChatCompositeBar, ChatHeader, ChatViewSessionActivityBar, RemoteHostUnavailableEmptyState, SessionReadOnlyBanner, SessionRemoteConnection, + SessionsListNotification, SessionsInboxScope, SessionStorageCleanupNotice, SessionsListFilters, + ]; + for (const root of roots) { + visit(root); + } + + assert.deepStrictEqual([...missing].sort(), []); + }); + + test('the session host of an editor window, created as the window starts, does nothing until a feature binds what it shows', () => { + // Records every use of its only service: it neither listens to it nor reads it until something reads the host + const used: string[] = []; + const contextKeyService = new Proxy({}, { get: (_target, property) => { used.push(String(property)); return undefined; } }) as IContextKeyService; + const host = new EditorWindowMainSessionHost(contextKeyService); + + assert.deepStrictEqual({ used, content: host.content.get(), session: host.session.get(), chat: host.chat.get() }, { used: [], content: { kind: 'empty' }, session: undefined, chat: undefined }); + }); +}); diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsViewProviders.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsViewProviders.test.ts new file mode 100644 index 00000000000000..0a68bbd2c60cf3 --- /dev/null +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsViewProviders.test.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { constObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { isEqual } from '../../../../../../base/common/resources.js'; +import { upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../../../base/test/common/virtualScheduling/index.js'; +import { IChat, ISession } from '../../../../../services/sessions/common/session.js'; +import { AGENTS_VIEW_SESSION_WAIT_TIMEOUT, waitForAgentsViewSession } from '../../../browser/agentsView/agentsViewProviders.js'; +import { ISessionChange, ISessionsManagementService } from '../../../common/sessionsManagement.js'; +import { buildTestSession } from '../../common/testSessionBuilder.js'; + +suite('waitForAgentsViewSession', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('finds a peer chat that its listed session publishes later, without a change of the sessions', () => runWithFakedTimers({}, async () => { + const built = buildTestSession({ id: 'parent', title: 'Parent', chats: [{ id: 'peer', title: 'Peer' }] }); + const peer = built.chats.get('peer')!.chat; + const chats = observableValue('chats', [built.mainChat.chat]); + const parent: ISession = { ...built.session, chats }; + const sessionsManagementService = upcastPartial({ + onDidChangeSession: Event.None, + getSessions: () => [parent], + getSession: () => undefined, + }); + const find = () => parent.chats.get().find(chat => isEqual(chat.resource, peer.resource)); + + const found = waitForAgentsViewSession(sessionsManagementService, peer.resource, find); + await timeout(1000); + chats.set([built.mainChat.chat, peer], undefined); + + assert.strictEqual(await found, peer); + })); + + test('finds a peer chat that a session outside the listing scope, known before the wait, publishes without a change of the sessions', () => runWithFakedTimers({}, async () => { + const built = buildTestSession({ id: 'parent', title: 'Parent' }); + const peerResource = built.session.resource.with({ fragment: 'peer' }); + const peer: IChat = { ...built.mainChat.chat, resource: peerResource }; + const chats = observableValue('chats', [built.mainChat.chat]); + const parent: ISession = { ...built.session, chats, capabilities: constObservable({ ...built.session.capabilities.get(), supportsMultipleChats: true }) }; + const sessionsManagementService = upcastPartial({ + // Not listed, and no change of it is reported: only its lookup finds it + onDidChangeSession: Event.None, + getSessions: () => [], + getSession: resource => isEqual(resource, parent.resource) ? parent : undefined, + }); + const find = () => parent.chats.get().find(chat => isEqual(chat.resource, peerResource)); + + const start = Date.now(); + const found = waitForAgentsViewSession(sessionsManagementService, peerResource, find); + await timeout(1000); + chats.set([built.mainChat.chat, peer], undefined); + + assert.deepStrictEqual({ found: await found, beforeTimeout: Date.now() - start < AGENTS_VIEW_SESSION_WAIT_TIMEOUT }, { found: peer, beforeTimeout: true }); + })); + + test('finds a session that its provider does not list, such as of another folder, as soon as the provider knows it', () => runWithFakedTimers({}, async () => { + const elsewhere = buildTestSession({ id: 'elsewhere', title: 'Elsewhere' }).session; + const allSessions: ISession[] = []; + const sessionChanged = new Emitter(); + const sessionsManagementService = upcastPartial({ + onDidChangeSession: sessionChanged.event, + getSessions: () => [], + getSession: () => undefined, + }); + + const start = Date.now(); + const found = waitForAgentsViewSession(sessionsManagementService, elsewhere.resource, () => allSessions.find(session => isEqual(session.resource, elsewhere.resource))); + await timeout(10); + allSessions.push(elsewhere); + sessionChanged.fire([{ session: elsewhere, kind: 'added' }]); + + assert.deepStrictEqual({ found: await found, beforeTimeout: Date.now() - start < AGENTS_VIEW_SESSION_WAIT_TIMEOUT }, { found: elsewhere, beforeTimeout: true }); + sessionChanged.dispose(); + })); +}); diff --git a/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts b/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts new file mode 100644 index 00000000000000..8ee013ad009101 --- /dev/null +++ b/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Emitter } from '../../../../../../base/common/event.js'; +import { toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IWorkbenchLayoutService, Parts } from '../../../../../services/layout/browser/layoutService.js'; +import { ILifecycleService } from '../../../../../services/lifecycle/common/lifecycle.js'; +import { IViewsService } from '../../../../../services/views/common/viewsService.js'; +import { IAgentsViewProvidersService } from '../../../browser/agentsView/agentsViewProviders.js'; +import { EditorWindowSessionsRuntime } from '../../../browser/editorWindow/editorWindowSessionsRuntime.js'; + +suite('EditorWindowSessionsRuntime', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createRuntime(state: { readonly views: Set; chatPart: boolean }, events: string[]) { + const onDidChangeViewVisibility = disposables.add(new Emitter<{ id: string; visible: boolean }>()); + const onDidChangePartVisibility = disposables.add(new Emitter<{ partId: string; visible: boolean }>()); + const listen = (emitter: Emitter, name: string) => (listener: (e: T) => unknown) => { + events.push(`listen ${name}`); + const listening = emitter.event(listener); + return toDisposable(() => { events.push(`unlisten ${name}`); listening.dispose(); }); + }; + const runtime = disposables.add(new EditorWindowSessionsRuntime( + upcastPartial({ onDidChangeViewVisibility: listen(onDidChangeViewVisibility, 'views'), isViewVisible: id => state.views.has(id) }), + upcastPartial({ onDidChangePartVisibility: listen(onDidChangePartVisibility, 'parts'), isVisible: part => part === Parts.CHAT_PART && state.chatPart }), + upcastPartial({ when: () => Promise.resolve() }), + upcastPartial({ acquire: () => { events.push('acquire'); return toDisposable(() => events.push('release')); } }), + )); + const showView = (id: string, visible: boolean) => { + if (visible) { + state.views.add(id); + } else { + state.views.delete(id); + } + onDidChangeViewVisibility.fire({ id, visible }); + }; + const showChatPart = (visible: boolean) => { + state.chatPart = visible; + onDidChangePartVisibility.fire({ partId: Parts.CHAT_PART, visible }); + }; + return { runtime, showView, showChatPart }; + } + + test('listens to nothing and holds nothing until a feature enables it, also while what shows sessions shows', async () => { + const events: string[] = []; + const { runtime, showView, showChatPart } = createRuntime({ views: new Set(['list']), chatPart: true }, events); + showView('list', true); + showChatPart(true); + await Promise.resolve(); + + assert.deepStrictEqual({ events, enabled: runtime.enabled.get() }, { events: [], enabled: false }); + }); + + test('holds the providers while a view of a feature that enabled it, or the chat part, shows, as the window restores too', async () => { + const events: string[] = []; + const { runtime, showView, showChatPart } = createRuntime({ views: new Set(), chatPart: true }, events); + const step = (label: string, action: () => void) => { events.push(`-- ${label}`); action(); }; + + // The window restores with the chat part shown + const list = runtime.enable({ viewIds: ['list'] }); + step('the list shows', () => showView('list', true)); + step('the chat part hides', () => showChatPart(false)); + step('a view of no feature shows', () => showView('other', true)); + step('another feature enables it with its view', () => runtime.enable({ viewIds: ['other'] }).dispose()); + step('the list hides', () => showView('list', false)); + step('the chat part shows', () => showChatPart(true)); + step('the last feature disables it', () => list.dispose()); + step('the list shows', () => showView('list', true)); + + assert.deepStrictEqual(events, [ + 'listen views', 'listen parts', 'acquire', + '-- the list shows', + '-- the chat part hides', + '-- a view of no feature shows', + '-- another feature enables it with its view', + '-- the list hides', 'release', + '-- the chat part shows', 'acquire', + '-- the last feature disables it', 'unlisten views', 'unlisten parts', 'release', + '-- the list shows', + ]); + }); +}); diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index 76d73bf7921409..1b540d796dc78c 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -242,6 +242,7 @@ import './contrib/mcp/browser/mcp.contribution.js'; import './contrib/mcp/browser/mcp.view.contribution.js'; import './contrib/chat/browser/chatSessions/chatSessions.contribution.js'; import './contrib/sessions/browser/editorWindow/editorWindowSessions.contribution.js'; +import './contrib/sessions/providers/agentsView/browser/agentsViewProviders.contribution.js'; import './contrib/chat/browser/contextContrib/chatContext.contribution.js'; import './contrib/imageCarousel/browser/imageCarousel.contribution.js'; From 82c6c4cb2e751a50b3c1e3b1419ce1146c28c734 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:05:08 +0100 Subject: [PATCH 016/165] Sessions: the workspace picker's folder sections come from the shared folder picker sections The Folder (or Folders) and Worktrees sections of a composer restricted to a window's folders, their order and their labels (Current, New Worktree) now come from folderLocationSections (#348304), which the Chat view's folder chip shares, rather than from rules of its own. The picker shows the same items as before. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/chat/sessionWorkspacePicker.ts | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts index c8c1e201c70e34..90cc2e666499c1 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts +++ b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts @@ -41,6 +41,7 @@ import { IProgressService, ProgressLocation } from '../../../../../platform/prog import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { IFolderLocation } from '../../../../../platform/workspace/common/folderLocations.js'; +import { FolderLocationKind, getCurrentFolderLocationDescription, getNewWorktreeLabel, groupFolderLocations } from '../../../../services/workspaces/common/folderLocationSections.js'; import { SESSION_WORKTREE_ICON } from '../../common/sessionWorktreeIcon.js'; import { IGitHubInfo, isActiveSessionStatus, ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_GITHUB, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../../services/sessions/common/session.js'; import { ISessionsProvidersService } from '../sessionsProvidersService.js'; @@ -167,7 +168,7 @@ export interface IAllowedFolderPresentation extends Partial folder.item)))); } - // New Worktree first, then the window's folders and the worktrees, each in a section with the current one first + // New Worktree first, then the sections that every picker of a window's folders and their worktrees shows const newWorktreeItem = this._getNewWorktreeItem(); const items = this._withNoWorkspaceItem(newWorktreeItem ? [newWorktreeItem] : []); - const section = (title: string, sectionFolders: readonly { readonly item: IActionListItem; readonly current: boolean }[]) => { - if (sectionFolders.length) { - items.push({ kind: ActionListItemKind.Separator, label: title }); - items.push(...sectionFolders.filter(folder => folder.current).map(folder => folder.item), ...sectionFolders.filter(folder => !folder.current).map(folder => folder.item)); - } - }; - const windowFolders = folders.filter(folder => folder.kind !== 'worktree'); - section(windowFolders.length > 1 ? localize('workspacePicker.folders', "Folders") : localize('workspacePicker.folder', "Folder"), windowFolders); - section(localize('workspacePicker.worktrees', "Worktrees"), folders.filter(folder => folder.kind === 'worktree')); + for (const section of groupFolderLocations(folders.map(folder => ({ item: folder.item, kind: folder.kind ?? 'folder', current: folder.current })))) { + items.push({ kind: ActionListItemKind.Separator, label: section.title }, ...section.items); + } return this._withSelectionMarks(items); } @@ -2921,7 +2916,7 @@ export class WorkspacePicker extends Disposable { kind: ActionListItemKind.Action, label: presentation?.label ?? resolved.workspace.label, description: current - ? localize('workspacePicker.current', "Current") + ? getCurrentFolderLocationDescription() : presentation?.label !== undefined ? presentation.description : resolved.workspace.description, group: { title: '', icon: presentation?.icon ?? this._getWorkspaceIcon(resolved.workspace) }, disabled: this._isProviderUnavailable(resolved.providerId), From 6ad6b5f6ade89eaa1352cbdc0641de7221582dd5 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:07:17 +0100 Subject: [PATCH 017/165] Chat view: report 'restore' only for the chat that the view restores, not one that the user loads meanwhile The Chat view marked every chat it showed while its restore was under way as restored, also a chat that the user loaded meanwhile and that superseded the restore. It now passes the reason with the chat it shows, and reports it only while it sets that chat. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../widgetHosts/viewPane/chatViewPane.ts | 35 ++++++++------ .../widgetHosts/viewPane/chatViewPane.test.ts | 48 ++++++++++++++++++- 2 files changed, 67 insertions(+), 16 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 43fa62ff15327f..f6fb97b444ca79 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -170,8 +170,8 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private readonly activityBadge = this._register(new MutableDisposable()); private readonly _currentSessionResource = observableValue(this, undefined); - /** Whether the view restores the chat it last showed, rather than showing one that the user brought up. */ - private _restoringModel = false; + /** Why the view sets the chat it shows, while it sets it: it restores the chat it last showed, or the user brought one up. */ + private _showReason: IChatViewHostShownSession['reason'] = 'open'; private readonly _shownSession = observableValue(this, undefined); /** The chat the view shows, and whether it shows it as the user brought it up or as it restored it. */ readonly shownSession: IObservable = this._shownSession; @@ -384,10 +384,8 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { try { this._widget.setVisible(false); - this._restoringModel = true; - await this.showModel(cts.token, session.modelRef, true, !session.modelRef, undefined, session.localFallbackSelectionReason); + await this.showModel(cts.token, session.modelRef, true, !session.modelRef, undefined, session.localFallbackSelectionReason, 'restore'); } finally { - this._restoringModel = false; this._widget.setVisible(wasVisible); } }).catch(error => { @@ -1254,8 +1252,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { const model = chatWidget.viewModel?.model; this.titleControl?.update(model); this._currentSessionResource.set(chatWidget.viewModel?.sessionResource, undefined); - const shownResource = chatWidget.viewModel?.sessionResource; - this._shownSession.set(shownResource && { resource: shownResource, reason: this._restoringModel ? 'restore' : 'open' }, undefined); + this.updateShownSession(); if (this.sessionsViewerOrientation === AgentSessionsViewerOrientation.Stacked) { return; // only reveal in side-by-side mode @@ -1395,12 +1392,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private async _applyModel(token: CancellationToken): Promise { const session = await this.acquireTransferredOrPersistedSession(token, 'ChatViewPane#applyModel'); - this._restoringModel = true; - try { - await this.showModel(token, session.modelRef, true, !session.modelRef, undefined, session.localFallbackSelectionReason); - } finally { - this._restoringModel = false; - } + await this.showModel(token, session.modelRef, true, !session.modelRef, undefined, session.localFallbackSelectionReason, 'restore'); } /** @@ -1510,7 +1502,14 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { && sessionType === localChatSessionType; } - private async showModel(token: CancellationToken, modelRef?: IChatModelReference | undefined, startNewSession = true, ignoreTransferredSession = false, inputBeforeLoad?: string, localFallbackSelectionReason?: SessionTypeSelectionReason): Promise { + /** Reports the chat that the view shows now ({@link shownSession}), as its widget's chat changes. */ + private updateShownSession(): void { + const shownResource = this._widget.viewModel?.sessionResource; + this._shownSession.set(shownResource && { resource: shownResource, reason: this._showReason }, undefined); + } + + /** @param showReason why it shows the chat ({@link shownSession}): only the view's own restore passes `restore`. */ + private async showModel(token: CancellationToken, modelRef?: IChatModelReference | undefined, startNewSession = true, ignoreTransferredSession = false, inputBeforeLoad?: string, localFallbackSelectionReason?: SessionTypeSelectionReason, showReason: IChatViewHostShownSession['reason'] = 'open'): Promise { if (token.isCancellationRequested || this._store.isDisposed) { modelRef?.dispose(); return undefined; @@ -1572,7 +1571,13 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { this._widget.unlockFromCodingAgent(); } this.viewState.sessionResource = model.sessionResource; - setModelPreservingInputTypedWhileLoading(this._widget, baselineInput, () => this._widget.setModel(model)); + // The reason holds only while the model is set, so a chat shown meanwhile never takes another's reason + this._showReason = showReason; + try { + setModelPreservingInputTypedWhileLoading(this._widget, baselineInput, () => this._widget.setModel(model)); + } finally { + this._showReason = 'open'; + } const widgetViewState = this.widgetViewStates.get(getComparisonKey(model.sessionResource)); if (widgetViewState) { this._widget.restoreViewState(widgetViewState); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index 17c349b7be14c0..1519cbf2e27c49 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -8,7 +8,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../../../.. import { CancellationError } from '../../../../../../../base/common/errors.js'; import { DeferredPromise, RunOnceScheduler, timeout } from '../../../../../../../base/common/async.js'; import { Emitter, Event } from '../../../../../../../base/common/event.js'; -import { constObservable } from '../../../../../../../base/common/observable.js'; +import { constObservable, IObservable, observableValue } from '../../../../../../../base/common/observable.js'; import { DisposableStore, MutableDisposable, toDisposable } from '../../../../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { upcastPartial } from '../../../../../../../base/test/common/mock.js'; @@ -20,6 +20,7 @@ import { NullLogService } from '../../../../../../../platform/log/common/log.js' import { AccountPolicyGateState } from '../../../../../../services/policies/common/accountPolicyService.js'; import { TestContextService, TestStorageService } from '../../../../../../test/common/workbenchTestServices.js'; import { ChatViewPane } from '../../../../browser/widgetHosts/viewPane/chatViewPane.js'; +import { IChatViewHostShownSession } from '../../../../browser/chatViewHost.js'; import { ChatWidget } from '../../../../browser/widget/chatWidget.js'; import { mainWindow } from '../../../../../../../base/browser/window.js'; import { ChatOpenDiagnosticsService } from '../../../../common/chatOpenDiagnosticsService.js'; @@ -166,11 +167,14 @@ suite('ChatViewPane session transitions', () => { function createPane(resolving: Promise = Promise.resolve(), preparing: Promise = Promise.resolve()) { const previousModel = upcastPartial({ sessionResource: previousResource }); const nextModel = upcastPartial({ sessionResource: nextResource }); + const shownSession = observableValue('shownSession', undefined); let shown: IChatModel | undefined = previousModel; let input = 'previous draft'; const bindings: (URI | undefined)[] = []; const disposed: URI[] = []; const locks: string[] = []; + /** Tells the pane its widget's chat changed, as the widget's onDidChangeViewModel does. */ + let didChangeViewModel = () => { }; const paneStore = store.add(new DisposableStore()); const modelRef = paneStore.add(new MutableDisposable()); const createReference = (model: IChatModel): IChatModelReference => Object.assign(toDisposable(() => { @@ -188,6 +192,7 @@ suite('ChatViewPane session transitions', () => { shown = model; input = ''; bindings.push(model?.sessionResource); + didChangeViewModel(); }, lockToCodingAgent: (_name: string, _displayName: string, sessionType: string) => { locks.push(sessionType); }, unlockFromCodingAgent: () => { locks.push('local'); }, @@ -200,8 +205,12 @@ suite('ChatViewPane session transitions', () => { loadSession(resource: URI): Promise; applyModel(): void; restoringSession: Promise | undefined; + readonly shownSession: IObservable; } = Object.assign(Object.create(ChatViewPane.prototype), { _store: paneStore, + _showReason: 'open', + _shownSession: shownSession, + shownSession, modelRef, widgetViewStates: new Map(), viewState: {}, @@ -222,6 +231,7 @@ suite('ChatViewPane session transitions', () => { agentSessionsService: { model: { getSession: () => undefined } }, updateActions: () => { }, }); + didChangeViewModel = () => (Reflect.get(pane, 'updateShownSession') as () => void).call(pane); return { pane, paneStore, widget, modelRef, nextModel, nextRef, bindings, disposed, locks }; } @@ -270,6 +280,42 @@ suite('ChatViewPane session transitions', () => { }); } + test('reports a restored chat as restored and a chat that the user loads as opened, also one that supersedes a restore under way', async () => { + const shown = (rig: ReturnType) => { const value = rig.pane.shownSession.get(); return value && `${value.resource.path} ${value.reason}`; }; + const savedResource = nextResource.with({ path: '/saved' }); + const savedReference = () => Object.assign(toDisposable(() => { }), { object: upcastPartial({ sessionResource: savedResource }) }); + + const restored = createPane(); + Object.assign(restored.pane, { acquireTransferredOrPersistedSession: async () => ({ modelRef: restored.nextRef }) }); + restored.pane.applyModel(); + await restored.pane.restoringSession; + const afterRestore = shown(restored); + Object.assign(restored.pane, { chatService: { acquireOrLoadSession: async () => savedReference() } }); + await restored.pane.loadSession(savedResource); + const afterLoad = shown(restored); + + // The user loads a chat while the restore still prepares its chat's configuration + const preparing = new DeferredPromise(); + const superseded = createPane(Promise.resolve(), preparing.p); + Object.assign(superseded.pane, { + acquireTransferredOrPersistedSession: async () => ({ modelRef: superseded.nextRef }), + chatService: { acquireOrLoadSession: async () => savedReference() }, + }); + superseded.pane.applyModel(); + const restoration = superseded.pane.restoringSession; + await timeout(0); + const loading = superseded.pane.loadSession(savedResource); + await preparing.complete(); + await loading; + await restoration; + + assert.deepStrictEqual({ afterRestore, afterLoad, superseded: shown(superseded) }, { + afterRestore: '/untitled-next restore', + afterLoad: '/saved open', + superseded: '/saved open', + }); + }); + test('cancelling a replacement releases its pending reference before provider activation completes', async () => { const resolving = new DeferredPromise(); const rig = createPane(resolving.p); From fd14c5ccd845d24004699b62efbfb289715eef6b Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:14:03 +0100 Subject: [PATCH 018/165] Editor window: worktree support follows only the user's latest choice, worktrees inside the folder, and stops on dispose and when off Review fixes for worktree support: - Any chat the user brings up, also one that moves nothing, no chat, and a move of the folder that is not its own supersede every switch asked for before, also a switch of a chat that the user opened. - A worktree inside the folder is a location to follow; a folder inside the folder's own location that is no worktree works there. - The working directory that a restored chat is first known to work in, as the sessions list loads, is where it works, not a change to follow; only a chat that the user opened follows it. - As it is disposed, no switch under way applies, what switches read is cancelled, and the terminals it marked are not marked. - With the setting off, it marks no terminals; as it turns off, it clears their marks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostWorktree.contribution.ts | 105 ++++++++++++---- .../agentHost/agentHostWorktree.test.ts | 113 ++++++++++++++++-- 2 files changed, 187 insertions(+), 31 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts index a8cc4223d99bdc..10ab350d982664 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts @@ -3,10 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { toErrorMessage } from '../../../../../../base/common/errorMessage.js'; -import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { autorun } from '../../../../../../base/common/observable.js'; @@ -41,6 +41,14 @@ import { IAgentSessionsService } from '../agentSessionsService.js'; import { AGENT_HOST_WORKTREE_LOCATIONS_PROVIDER_ID, AgentHostWorktreeLocationProvider } from './agentHostWorktreeLocations.js'; type WorktreeFolderSwitchDirection = 'toAlternate' | 'toPrimary'; + +/** The chat shown, the working directory it was last known to work in, and whether it follows once that is first known. */ +interface IShownChat { + readonly resource: URI; + workingDirectory: URI | undefined; + /** A chat that the user opened follows as its working directory first becomes known; one that the view restored does not. */ + readonly followOnceKnown: boolean; +} type WorktreeFolderSwitchOutcome = 'switched' | 'missing' | 'failed' | 'superseded'; type WorktreeFolderSwitchEvent = { @@ -92,11 +100,12 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe private _switching = 0; /** The moves it asked the workspace for that have not completed yet. */ private _pendingMoves = 0; + /** Cancels what switches read, such as whether a folder exists, as it is disposed. */ + private readonly _cts = new CancellationTokenSource(); /** Where it last moved the folder: that move is its own, not the user's. */ private _ownMove: URI | undefined; - /** The chat shown, and the working directory it was last known to work in. */ - private _shown: { readonly resource: URI; workingDirectory: URI | undefined } | undefined; + private _shown: IShownChat | undefined; /** Waits for the shown session's working directory to become known or to change. */ private readonly _sessionsListener = this._register(new MutableDisposable()); @@ -128,6 +137,13 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe if (environmentService.isSessionsWindow) { return; } + // Disposed, no switch under way applies, what it reads is cancelled, and the terminals it marked are not marked + this._register(toDisposable(() => { + this._request++; + this._foreground++; + this._cts.dispose(true); + this._clearTerminalMarks(); + })); this._updateEnablement(); this._register(_configurationService.onDidChangeConfiguration(e => { @@ -144,12 +160,16 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe this._register(autorun(reader => { const shown = _chatViewHostService.shownSession.read(reader); this._sessionsListener.clear(); - this._shown = shown && !isUntitledChatSession(shown.resource) ? { resource: shown.resource, workingDirectory: undefined } : undefined; + this._shown = shown && !isUntitledChatSession(shown.resource) ? { resource: shown.resource, workingDirectory: undefined, followOnceKnown: shown.reason === 'open' } : undefined; if (shown) { this._logTrigger(shown.resource); } - if (this._shown && shown?.reason === 'open') { + if (!shown || shown.reason === 'open') { + // The user's latest choice: no switch asked for before applies, also where this chat moves nothing + this._request++; this._foreground++; + } + if (this._shown?.followOnceKnown) { this._follow(this._shown, false); } else if (this._shown) { // A restored chat moves nothing, but the folder follows it as it comes to work elsewhere @@ -167,12 +187,20 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe this._foreground++; const own = this._ownMove && extUriBiasedIgnorePathCase.isEqual(this._ownMove, e.folder.uri); this._ownMove = undefined; + if (!own) { + // Moved by someone else, such as the user in the window's title: no switch asked for before applies + this._request++; + } // The user moved the folder themselves, such as in the window's title: the last chat opened there shows if (!own && e.reason === 'user' && this._enabled) { this._openLastSession(e.folder); } })); - this._register(_terminalService.onDidCreateInstance(instance => this._markTerminal(instance))); + this._register(_terminalService.onDidCreateInstance(instance => { + if (this._enabled) { + this._markTerminal(instance); + } + })); this._markTerminals(); } @@ -198,10 +226,12 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe this._enabled = enabled; if (enabled) { this._providerRegistration.value = this._folderLocationService.registerProvider(this._provider, { builtIn: true }); + this._markTerminals(); return; } this._providerRegistration.clear(); this._request++; + this._clearTerminalMarks(); const workspace = this._workspaceContextService.getWorkspace(); const folder = workspace.folders[0]; const atWorktree = !!folder && this._folderLocationService.getLocation(folder)?.providerId === AGENT_HOST_WORKTREE_LOCATIONS_PROVIDER_ID; @@ -225,21 +255,26 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe } /** Follows the shown agent host session on its own as its working directory becomes known or changes. */ - private _watchWorkingDirectory(shown: { readonly resource: URI; workingDirectory: URI | undefined }): void { + private _watchWorkingDirectory(shown: IShownChat): void { if (!isLocalAgentHostTarget(shown.resource.scheme)) { return; } this._sessionsListener.value = this._agentSessionsService.model.onDidChangeSessions(() => { const workingDirectory = this._workingDirectory(shown.resource); - if (this._shown === shown && workingDirectory && !extUriBiasedIgnorePathCase.isEqual(workingDirectory, shown.workingDirectory)) { - shown.workingDirectory = workingDirectory; + if (this._shown !== shown || !workingDirectory || extUriBiasedIgnorePathCase.isEqual(workingDirectory, shown.workingDirectory)) { + return; + } + // A restored chat's working directory, first known only as the sessions list loads, is where it works, not a change + const change = !!shown.workingDirectory || shown.followOnceKnown; + shown.workingDirectory = workingDirectory; + if (change) { this._follow(shown, true); } }); } /** Moves the folder to where the shown chat works: on the user's behalf, or on its own (`automatic`). */ - private _follow(shown: { readonly resource: URI; workingDirectory: URI | undefined }, automatic: boolean): void { + private _follow(shown: IShownChat, automatic: boolean): void { const folder = this._workspaceContextService.getWorkspace().folders[0]; const primary = this._workspaceContextService.getWorkspace().uri; if (!folder || !primary || !this._folderLocationService.canSetLocation(folder)) { @@ -254,7 +289,7 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe this._watchWorkingDirectory(shown); target = shown.workingDirectory; } - if (!target || target.scheme !== Schemas.file || extUriBiasedIgnorePathCase.isEqualOrParent(target, folder.uri)) { + if (!target || target.scheme !== Schemas.file || extUriBiasedIgnorePathCase.isEqual(target, folder.uri)) { return; } if (!this._enabled || (automatic && this._switching > 0)) { @@ -278,23 +313,36 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe private async _switch(session: URI, primary: URI, target: URI, foreground: number | undefined): Promise { const automatic = foreground !== undefined; const request = ++this._request; - const direction: WorktreeFolderSwitchDirection = extUriBiasedIgnorePathCase.isEqual(target, primary) ? 'toPrimary' : 'toAlternate'; + let direction: WorktreeFolderSwitchDirection = 'toPrimary'; this._switching++; try { - if (direction === 'toAlternate') { - if (!await this._exists(target)) { - if (request === this._request && !automatic) { - this._notificationService.info(localize('worktree.missing', "The folder {0} of this chat no longer exists.", this._labelService.getUriLabel(target, { noPrefix: true }))); - } - this._log(direction, 'missing', automatic); + if (!extUriBiasedIgnorePathCase.isEqual(target, primary)) { + // A worktree, also one inside the folder; else a folder inside the folder's own location works there + const exists = await this._exists(target); + const location = exists ? await this._folderLocationService.resolveLocation(primary, target, this._cts.token) : undefined; + if (this._store.isDisposed) { return; } - if (!await this._folderLocationService.resolveLocation(primary, target, CancellationToken.None)) { - // A working directory that is no worktree of the folder: the chat opens in place + if (location) { + direction = 'toAlternate'; + target = location.uri; + } else if (extUriBiasedIgnorePathCase.isEqualOrParent(target, primary)) { + target = primary; + } else { + if (!exists) { + if (request === this._request && !automatic) { + this._notificationService.info(localize('worktree.missing', "The folder {0} of this chat no longer exists.", this._labelService.getUriLabel(target, { noPrefix: true }))); + } + this._log('toAlternate', 'missing', automatic); + } + // Elsewhere, such as a folder that is no worktree of the folder: the chat opens in place return; } } - // Read again right before the move: it may have been turned off, or a later request came, meanwhile + if (this._isAt(target)) { + return; + } + // Read again right before the move: it may have been turned off, disposed, or a later choice came, meanwhile if (request !== this._request || !this._enabled || (automatic && foreground !== this._foreground)) { this._log(direction, 'superseded', automatic); return; @@ -310,6 +358,9 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe } finally { this._pendingMoves--; } + if (this._store.isDisposed) { + return; + } const applied = request === this._request && this._isAt(target); if (automatic && applied) { this._rememberSession(target, session); @@ -351,6 +402,9 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe } private _markTerminals(): void { + if (!this._enabled) { + return; + } const folder = this._workspaceContextService.getWorkspace().folders[0]; if (folder && !this._visitedLocations.some(location => extUriBiasedIgnorePathCase.isEqual(location, folder.uri))) { this._visitedLocations.push(folder.uri); @@ -380,6 +434,13 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe } } + private _clearTerminalMarks(): void { + this._visitedLocations.length = 0; + for (const instance of this._terminalService.instances) { + instance.statusList.remove(FOLDER_LOCATION_TERMINAL_STATUS_ID); + } + } + private _rememberSession(location: URI, session: URI): void { // A chat is the last one of the location it works at only, and the most recent is last, so the oldest goes first for (const [known, resource] of [...this._lastSessions]) { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts index 872709786c3588..2815e8bd9f7117 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { DeferredPromise } from '../../../../../../../base/common/async.js'; import { Emitter, Event } from '../../../../../../../base/common/event.js'; import { toDisposable } from '../../../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../../../base/common/observable.js'; @@ -24,11 +25,11 @@ import { IStorageService } from '../../../../../../../platform/storage/common/st import { ITelemetryService } from '../../../../../../../platform/telemetry/common/telemetry.js'; import { TestExperimentTriggerTelemetryService } from '../../../../../../../platform/telemetry/test/common/experimentTriggerTestUtils.js'; import { IFolderLocationChangeEvent, IResolvedFolderLocation, IWorkspaceFolderLocationService } from '../../../../../../../platform/workspace/common/folderLocations.js'; -import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../../platform/workspace/common/workspace.js'; +import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder, IWorkspaceFoldersChangeEvent } from '../../../../../../../platform/workspace/common/workspace.js'; import { IWorkbenchEnvironmentService } from '../../../../../../services/environment/common/environmentService.js'; import { IWorkspaceEditingService } from '../../../../../../services/workspaces/common/workspaceEditing.js'; import { TestStorageService } from '../../../../../../test/common/workbenchTestServices.js'; -import { ITerminalService } from '../../../../../terminal/browser/terminal.js'; +import { ITerminalInstance, ITerminalService } from '../../../../../terminal/browser/terminal.js'; import { AgentHostWorktreeContribution } from '../../../../browser/agentSessions/agentHost/agentHostWorktree.contribution.js'; import { IAgentSession } from '../../../../browser/agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../../../../browser/agentSessions/agentSessionsService.js'; @@ -46,11 +47,18 @@ suite('AgentHostWorktree', () => { const created = URI.file('/work/vscode.worktrees/created'); /** The feature in a window whose folder is at `at`, recording the moves it asks for, the chats it opens and its experiment triggers. */ - function createWorktree(options: { readonly enabled: boolean; readonly at?: URI; readonly canSetLocation?: boolean }) { + function createWorktree(options: { readonly enabled: boolean; readonly at?: URI; readonly canSetLocation?: boolean; readonly worktrees?: readonly URI[]; readonly resolving?: Promise; readonly terminalCwd?: URI }) { + const worktrees = options.worktrees ?? [worktree, created]; + const marks: string[] = []; + const terminal = upcastPartial({ + initialCwd: options.terminalCwd?.fsPath, + statusList: upcastPartial({ add: () => { marks.push('marked'); }, remove: () => { marks.push('unmarked'); } }), + }); const shownSession = observableValue('shownSession', undefined); const workingDirectories = new Map(); const onDidChangeSessions = store.add(new Emitter()); const onDidChangeLocation = store.add(new Emitter()); + const onDidChangeWorkspaceFolders = store.add(new Emitter()); const workspace = { at: options.at ?? primary }; const moves: string[] = []; const opened: string[] = []; @@ -77,40 +85,48 @@ suite('AgentHostWorktree', () => { instantiationService.stub(IContextKeyService, contextKeyService); instantiationService.stub(IWorkspaceContextService, upcastPartial({ getWorkspace: () => upcastPartial({ uri: primary, folders: [folder()] }), - onDidChangeWorkspaceFolders: Event.None, + onDidChangeWorkspaceFolders: onDidChangeWorkspaceFolders.event, onDidChangeWorkbenchState: Event.None, })); instantiationService.stub(IWorkspaceFolderLocationService, upcastPartial({ canSetLocation: () => options.canSetLocation ?? true, onDidChangeLocation: onDidChangeLocation.event, getLocation: () => workspace.at.path === primary.path ? undefined : upcastPartial({ providerId: 'agentHost.sessionWorktrees' }), - resolveLocation: async (_primary: URI, location: URI) => upcastPartial({ uri: location }), + resolveLocation: async (_primary: URI, location: URI) => { + await options.resolving; + return worktrees.some(known => known.path === location.path) ? upcastPartial({ uri: location }) : undefined; + }, registerProvider: () => { providers.push('registered'); return toDisposable(() => providers.push('unregistered')); }, })); instantiationService.stub(IWorkspaceEditingService, upcastPartial({ setFolderLocation: async (location: URI, moveOptions?: { readonly ifIdle?: boolean; readonly reason?: string }) => { moves.push(`${location.path}${moveOptions?.ifIdle ? ' (automatic)' : ''}`); workspace.at = location; + onDidChangeWorkspaceFolders.fire({ added: [], removed: [], changed: [folder()] }); onDidChangeLocation.fire({ folder: folder(), reason: moveOptions?.reason === 'user' ? 'user' : 'programmatic' }); }, })); instantiationService.stub(IFileService, upcastPartial({ stat: async () => upcastPartial({}) })); instantiationService.stub(ILabelService, upcastPartial({ getUriLabel: (uri: URI) => uri.path })); instantiationService.stub(INotificationService, new TestNotificationService()); - instantiationService.stub(ITerminalService, upcastPartial({ instances: [], onDidCreateInstance: Event.None })); + instantiationService.stub(ITerminalService, upcastPartial({ instances: options.terminalCwd ? [terminal] : [], onDidCreateInstance: Event.None })); instantiationService.stub(IStorageService, store.add(new TestStorageService())); instantiationService.stub(ITelemetryService, telemetryService); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IWorkbenchEnvironmentService, upcastPartial({ isSessionsWindow: false })); - store.add(instantiationService.createInstance(AgentHostWorktreeContribution)); + const contribution = store.add(instantiationService.createInstance(AgentHostWorktreeContribution)); const show = (resource: URI, reason: IChatViewHostShownSession['reason'] = 'open') => shownSession.set({ resource, reason }, undefined); const setEnabled = (enabled: boolean) => { configurationService.setUserConfiguration(ChatConfiguration.EditorWindowEnableWorktree, enabled); configurationService.onDidChangeConfigurationEmitter.fire({ affectsConfiguration: (key: string) => key === ChatConfiguration.EditorWindowEnableWorktree } as never); }; - const userMovesTo = (location: URI) => { workspace.at = location; onDidChangeLocation.fire({ folder: folder(), reason: 'user' }); }; - return { show, workingDirectories, onDidChangeSessions, moves, opened, providers, triggers: telemetryService.triggers, setEnabled, userMovesTo }; + const userMovesTo = (location: URI) => { + workspace.at = location; + onDidChangeWorkspaceFolders.fire({ added: [], removed: [], changed: [folder()] }); + onDidChangeLocation.fire({ folder: folder(), reason: 'user' }); + }; + return { show, workingDirectories, onDidChangeSessions, moves, opened, providers, triggers: telemetryService.triggers, setEnabled, userMovesTo, contribution, marks }; } const settle = () => new Promise(resolve => setTimeout(resolve, 0)); @@ -194,4 +210,83 @@ suite('AgentHostWorktree', () => { notMovable: { moves: [], triggers: [] }, }); }); + + test('a later choice, also one that moves nothing, or a move of the user\'s own, supersedes a switch still under way', async () => { + const run = async (supersede: (feature: ReturnType) => void) => { + const resolving = new DeferredPromise(); + const feature = createWorktree({ enabled: true, resolving: resolving.p }); + feature.workingDirectories.set(agentSession('a').toString(), worktree.fsPath); + feature.show(agentSession('a')); + await settle(); + supersede(feature); + resolving.complete(); + await settle(); + return feature.moves; + }; + assert.deepStrictEqual({ + chatThatMovesNothing: await run(feature => feature.show(localChat)), + userMove: await run(feature => feature.userMovesTo(primary.with({ path: '/work/elsewhere' }))), + }, { chatThatMovesNothing: [], userMove: [] }); + }); + + test('follows to a worktree inside the folder, and back to the folder for a chat that works in a folder inside it', async () => { + const nested = URI.file('/work/vscode/.worktrees/nested'); + const feature = createWorktree({ enabled: true, worktrees: [nested] }); + feature.workingDirectories.set(agentSession('nested').toString(), nested.fsPath); + feature.workingDirectories.set(agentSession('src').toString(), URI.file('/work/vscode/src').fsPath); + feature.show(agentSession('nested')); + await settle(); + feature.show(agentSession('src')); + await settle(); + const atFolder = [...feature.moves]; + feature.show(agentSession('src')); + await settle(); + + assert.deepStrictEqual({ atFolder, again: feature.moves }, { + atFolder: ['/work/vscode/.worktrees/nested', '/work/vscode'], + again: ['/work/vscode/.worktrees/nested', '/work/vscode'], + }); + }); + + test('a restored chat\'s working directory that becomes known as the list loads moves nothing, but a later change does', async () => { + const feature = createWorktree({ enabled: true }); + feature.show(agentSession('restored'), 'restore'); + await settle(); + feature.workingDirectories.set(agentSession('restored').toString(), worktree.fsPath); + feature.onDidChangeSessions.fire(); + await settle(); + const hydrated = [...feature.moves]; + feature.workingDirectories.set(agentSession('restored').toString(), created.fsPath); + feature.onDidChangeSessions.fire(); + await settle(); + + assert.deepStrictEqual({ hydrated, changed: feature.moves }, { hydrated: [], changed: ['/work/vscode.worktrees/created (automatic)'] }); + }); + + test('a switch under way does not apply once disposed', async () => { + const resolving = new DeferredPromise(); + const feature = createWorktree({ enabled: true, resolving: resolving.p }); + feature.workingDirectories.set(agentSession('a').toString(), worktree.fsPath); + feature.show(agentSession('a')); + await settle(); + feature.contribution.dispose(); + resolving.complete(); + await settle(); + + assert.deepStrictEqual(feature.moves, []); + }); + + test('marks terminals only while on, and clears their marks as it turns off', async () => { + const off = createWorktree({ enabled: false, at: worktree, terminalCwd: primary }); + off.userMovesTo(primary); + off.userMovesTo(worktree); + const on = createWorktree({ enabled: true, at: worktree, terminalCwd: primary }); + on.userMovesTo(primary); + on.userMovesTo(worktree); + const whileOn = [...on.marks]; + on.setEnabled(false); + await settle(); + + assert.deepStrictEqual({ off: off.marks, whileOn: whileOn.includes('marked'), afterOff: on.marks.at(-1) }, { off: [], whileOn: true, afterOff: 'unmarked' }); + }); }); From 174131c5200bbc0b86fa6205a8e26d18606be9a5 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:14:16 +0100 Subject: [PATCH 019/165] Sessions: the chat part of editor windows, for any feature to enable Moves the chat part that the Sessions view feature and the sessions in the Chat view feature each carried into one setting-less service, inert until a feature enables it: - IEditorWindowChatPartService.enable({ anchorViewId, anchorPriority, enabled }) creates the part before the grid, docks it beside the anchor of the highest priority, and registers its actions and the Changes editor. While enabled, it restores with the window, replaces the Chat view through the chat part's IChatViewHost and presents the window's sessions; while not, it does not restore and hides. - AgentsChatViewHost leaves chats it cannot show, such as local chat, to the Chat view (canShow), opens New Chat as a composer, reports the active tab's chat as shownSession ('restore' for the tabs restore() showed, else 'open'), and stops opening the chat part once deactivated. - IAgentsChatPartService gets shownSession and a composer folder seam (setComposerFolderOptions), the window's folders by default. - The chat part's context keys move into agentsChatPartContext.ts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentsView/agentsChatPart.ts | 602 +++++++++++++++++ .../agentsView/agentsChatPartActions.ts | 218 ++++++ .../agentsView/agentsChatPartContext.ts | 24 + .../agentsView/agentsChatPartContribution.ts | 190 ++++++ .../browser/agentsView/agentsChatPartFrame.ts | 45 ++ .../agentsView/agentsChatPartTelemetry.ts | 24 + .../browser/agentsView/agentsChatViewHost.ts | 217 ++++++ .../browser/chatEditor/chatEditorContent.ts | 1 + .../chatEditor/sessionChatEditorHost.ts | 2 + .../editorWindowSessions.contribution.ts | 8 + .../test/browser/agentsViewServices.test.ts | 6 +- .../browser/agentsView/agentsChatPart.test.ts | 638 ++++++++++++++++++ .../agentsView/agentsChatPartActions.test.ts | 120 ++++ .../agentsView/agentsChatViewHost.test.ts | 257 +++++++ .../editorWindowChatPartService.test.ts | 126 ++++ 15 files changed, 2476 insertions(+), 2 deletions(-) create mode 100644 src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts create mode 100644 src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartActions.ts create mode 100644 src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContext.ts create mode 100644 src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContribution.ts create mode 100644 src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartFrame.ts create mode 100644 src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartTelemetry.ts create mode 100644 src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts create mode 100644 src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts create mode 100644 src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPartActions.test.ts create mode 100644 src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts create mode 100644 src/vs/workbench/contrib/sessions/test/browser/agentsView/editorWindowChatPartService.test.ts diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts new file mode 100644 index 00000000000000..3414c094618f63 --- /dev/null +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts @@ -0,0 +1,602 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $ } from '../../../../../base/browser/dom.js'; +import { raceTimeout } from '../../../../../base/common/async.js'; +import { onUnexpectedError } from '../../../../../base/common/errors.js'; +import { Event } from '../../../../../base/common/event.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { constObservable, derived, IObservable, observableFromEvent, observableValue, transaction, waitForState } from '../../../../../base/common/observable.js'; +import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { isEqual } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { createDecorator, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { EditorInput } from '../../../../common/editor/editorInput.js'; +import { GroupDirection, IContentPart, IEditorGroup, IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IWorkbenchLayoutService, Parts } from '../../../../services/layout/browser/layoutService.js'; +import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js'; +import { ISessionHost } from '../../../../services/sessions/browser/sessionHost.js'; +import { IChat, ISession } from '../../../../services/sessions/common/session.js'; +import { IChatWidget } from '../../../chat/browser/chat.js'; +import { ChatContextKeys } from '../../../chat/common/actions/chatContextKeys.js'; +import { IChatService } from '../../../chat/common/chatService/chatService.js'; +import { getChatWindowMainPartOptions } from '../../../chatWindow/browser/chatWindow.js'; +import { enforceChatPartOptions } from '../../../chatWindow/browser/chatWindowService.js'; +import { ChatWindowLayoutMode } from '../../../chatWindow/common/chatWindowLayout.js'; +import { ISessionsManagementService } from '../../common/sessionsManagement.js'; +import { IChatEditorWindow, registerSessionChatViewEditor, SESSION_CHAT_VIEW_WORKING_SET_HOST_OPTIONS } from '../chatEditor/chatEditor.js'; +import { ChatWindowComposerModel, ChatWindowComposers, createChatWindowComposerResource, IChatWindowComposerInit, isChatWindowComposerResource } from '../chatEditor/chatWindowComposer.js'; +import { IChatViewHostShownSession } from '../../../chat/browser/chatViewHost.js'; +import { SessionChatEditorEntry, waitForSessionChat } from '../chatEditor/sessionChatEditorHost.js'; +import { IChatViewFactory } from '../chatViewFactory.js'; +import { INewSessionTarget } from '../parts/chatView.js'; +import { ISessionsListModelService } from '../sessionsListModelService.js'; +import { ISessionsProvidersService } from '../sessionsProvidersService.js'; +import { IAgentsChatPartFrame } from './agentsChatPartFrame.js'; +import { AgentsChatPartAction, logAgentsChatPartAction } from './agentsChatPartTelemetry.js'; +import { AgentsChatPartAvailable, InAgentsChatPartContext } from './agentsChatPartContext.js'; +import { EditorWindowMainSessionHost } from './editorWindowMainSessionHost.js'; + +/** The chats of the chat part, which it shows again as the window restores. */ +const CHAT_PART_EDITORS_STORAGE_KEY = 'workbench.chatPart.editors'; + +/** How long restoring a chat of the chat part waits for its session to load. */ +const RESTORE_SESSION_TIMEOUT = 30_000; + +/** How long the chat part waits for the view of a composer to show. */ +const COMPOSER_VIEW_TIMEOUT = 5_000; + +/** A chat that the chat part shows, with its session. */ +export interface IAgentsChatPartChat { + readonly session: ISession; + readonly chat: IChat; +} + +export interface IAgentsChatPartOpenOptions { + readonly preserveFocus?: boolean; + /** Opens beside the active chat, in a group of its own. */ + readonly sideBySide?: boolean; + /** Opens a new composer, also while the active tab is one that has not sent its session yet. */ + readonly newComposer?: boolean; +} + +export const IAgentsChatPartService = createDecorator('agentsChatPartService'); + +/** + * The chat part of an editor window with the Sessions view: it shows chats of sessions, from any workspace, each in a tab + * of its own, beside the editor area, as a chat window shows them. The chat of its active tab is the window's active chat. + */ +export interface IAgentsChatPartService { + readonly _serviceBrand: undefined; + + /** The host of the window, whose session and chat are those of the active tab of the chat part. */ + readonly mainWindowHost: ISessionHost; + + /** The chat of the active tab of the chat part, with its session. */ + readonly activeChat: IObservable; + + /** Shows `chatResource` of `session`, or its main chat, in the chat part: in its tab, or in a new one. */ + openChat(session: ISession, chatResource: URI | undefined, options?: IAgentsChatPartOpenOptions): Promise; + + /** Opens a new-session composer in a tab of the chat part, aimed at `target`. */ + openComposer(target: INewSessionTarget | undefined, options?: IAgentsChatPartOpenOptions): Promise; + + /** Closes the tab of `chatResource`, where the chat part shows it. */ + closeChat(chatResource: URI): Promise; + + /** Whether the chat part shows `chatResource` in one of its tabs. */ + isShowing(chatResource: URI): boolean; + + /** Whether one of its tabs is a new-session composer that has not sent its session yet. */ + hasComposer(): boolean; + + /** Closes the tabs of new-session composers with nothing typed or attached, and no send under way. */ + closeIdleComposers(): Promise; + + /** Moves focus into the chat part. */ + focus(): void; + + /** + * Shows the chat part with the chats it had as it closed, also those of before a reload, and moves focus into it unless + * `preserveFocus`. Without any, it shows a new-session composer, aimed at `target`. + */ + show(target: INewSessionTarget | undefined, options?: { readonly preserveFocus?: boolean }): Promise; + + /** The chat widget of the input of the composer in the active tab, once it shows: its submits create the session. */ + getComposerWidget(): Promise; + + /** Shows again the chats the chat part showed as the window last closed, when the layout restores it shown. */ + restore(): Promise; + + /** + * The chat of the active tab, and why it shows: `restore` while it is the tab that {@link restore} showed, `open` once + * the user opened, picked or sent a chat, as the Chat view reports it (see `IChatViewHost.shownSession`). + */ + readonly shownSession: IObservable; + + /** The folders its composers create sessions in, and how their workspace pickers show them: the window's folders by default. */ + getComposerFolderOptions(): IAgentsChatPartComposerFolderOptions; + + /** + * Has its composers offer the folders `provider` gives, such as the worktrees of the window's folder, until the returned + * disposable is disposed. The provider set last applies. + */ + setComposerFolderOptions(provider: () => IAgentsChatPartComposerFolderOptions): IDisposable; +} + +/** The folders the composers of the chat part create sessions in, besides the folder each aims at, and how they show them. */ +export type IAgentsChatPartComposerFolderOptions = Pick; + +/** + * Shows again the chats of the chat part that the layout restored shown. With chat hidden or disabled as the window starts, + * such as with AI features off, it hides the chat part instead, without its chats. + */ +export async function restoreAgentsChatPart(layoutService: IWorkbenchLayoutService, contextKeyService: IContextKeyService, chatPart: () => IAgentsChatPartService): Promise { + if (!layoutService.isVisible(Parts.CHAT_PART)) { + return; + } + if (!contextKeyService.contextMatchesRules(AgentsChatPartAvailable)) { + layoutService.setPartHidden(true, Parts.CHAT_PART); + return; + } + await chatPart().restore(); +} + +/** + * One chat of the chat part: a tab of it that shows a session's chat, or a new-session composer until a request it sends + * starts its session (see {@link SessionChatEditorEntry}). It closes its tab as the chat ends. + */ +class AgentsChatPartChat extends SessionChatEditorEntry { + + readonly active: IObservable; + /** As the editor window's chat, it shows its session's changes as the working set of its input. */ + readonly hostOptions = SESSION_CHAT_VIEW_WORKING_SET_HOST_OPTIONS; + + constructor( + chatResource: URI, + composer: ChatWindowComposerModel | undefined, + readonly window: IObservable, + activeInput: IObservable, + private readonly _close: (chat: AgentsChatPartChat) => void, + @ISessionsManagementService sessionsManagementService: ISessionsManagementService, + @IChatService chatService: IChatService, + @ISessionsListModelService sessionsListModelService: ISessionsListModelService, + @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + @IConfigurationService configurationService: IConfigurationService, + ) { + super(chatResource, sessionsManagementService, chatService, sessionsListModelService, sessionsProvidersService, configurationService); + this.active = derived(this, reader => activeInput.read(reader) === this.input); + this._register(this.onDidEnd(() => this._close(this))); + this.start(composer, 'AgentsChatPart#open'); + } + + protected closeShown(): void { + this._close(this); + } +} + +interface IStoredChatPartEditors { + readonly chats: readonly string[]; + readonly active: number; +} + +export class AgentsChatPartService extends Disposable implements IAgentsChatPartService { + + declare readonly _serviceBrand: undefined; + + private readonly _activeInput = observableValue(this, undefined); + /** Why the active tab became active: as {@link restore} showed it, or else as the user opened or picked it. */ + private readonly _activeReason = observableValue(this, 'open'); + readonly shownSession: IObservable; + private readonly _composerFolderProviders: (() => IAgentsChatPartComposerFolderOptions)[] = []; + readonly activeChat: IObservable; + + private _part: IContentPart | undefined; + private readonly _partStore = this._register(new DisposableStore()); + private readonly _chats = this._register(new DisposableMap()); + private _composers: ChatWindowComposers | undefined; + private readonly _window: IChatEditorWindow; + private _restored = false; + /** Whether the chat part changes its visibility as it restores, which it reports as restoring, not opening or closing. */ + private _restoring = false; + + constructor( + @ISessionHost readonly mainWindowHost: ISessionHost, + @IAgentsChatPartFrame private readonly _frame: IAgentsChatPartFrame, + @IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService, + @IEditorService private readonly _editorService: IEditorService, + @IWorkbenchLayoutService private readonly _layoutService: IWorkbenchLayoutService, + @IStorageService private readonly _storageService: IStorageService, + @ILifecycleService private readonly _lifecycleService: ILifecycleService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @IChatViewFactory private readonly _chatViewFactory: IChatViewFactory, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + @IWorkspaceContextService workspaceContextService: IWorkspaceContextService, + ) { + super(); + this._windowFolders = observableFromEvent(this, workspaceContextService.onDidChangeWorkspaceFolders, () => workspaceContextService.getWorkspace().folders.map(folder => folder.uri)); + + this.activeChat = derived(this, reader => { + const input = this._activeInput.read(reader); + return input ? this._chats.get(input)?.shown.read(reader) : undefined; + }); + this.shownSession = derived(this, reader => { + const active = this.activeChat.read(reader); + return active ? { resource: active.chat.resource, reason: this._activeReason.read(reader) } : undefined; + }); + // The window's host, as its service, shows the active tab's chat + if (this.mainWindowHost instanceof EditorWindowMainSessionHost) { + this._register(this.mainWindowHost.bindShownChat(this.activeChat)); + } + + // The chats show beside the editor area, whose editors their Session Activity Bars read + this._window = { + layoutMode: constObservable(ChatWindowLayoutMode.SideBySide), + inputOnly: constObservable(false), + resizing: constObservable(false), + mainPart: this._editorGroupsService.mainPart, + toggleConversation: () => { }, + }; + + this._register(this._layoutService.onDidChangePartVisibility(({ partId, visible }) => { + if (partId === Parts.CHAT_PART && !this._restoring) { + this._log(visible ? 'open' : 'close'); + } + })); + this._register(this._storageService.onWillSaveState(() => this._save())); + + // Chat turned off, such as as AI features are disabled, closes the chat part; it is off until chat starts + const chatEnabledKeys = new Set([ChatContextKeys.enabled.key]); + let chatEnabled = !!ChatContextKeys.enabled.getValue(_contextKeyService); + this._register(_contextKeyService.onDidChangeContext(e => { + if (!e.affectsSome(chatEnabledKeys)) { + return; + } + const enabled = !!ChatContextKeys.enabled.getValue(_contextKeyService); + if (chatEnabled && !enabled) { + this._layoutService.setPartHidden(true, Parts.CHAT_PART); + } + chatEnabled = enabled; + })); + + // Last: what reads the chat part once it exists reads all of it + this._frame.didCreateContent(); + } + + /** The folders of the window, the only ones its composers create sessions in by default, besides the folder each aims at. */ + private readonly _windowFolders: IObservable; + + getComposerFolderOptions(): IAgentsChatPartComposerFolderOptions { + return this._composerFolderProviders.at(-1)?.() ?? { allowedFolders: this._windowFolders }; + } + + setComposerFolderOptions(provider: () => IAgentsChatPartComposerFolderOptions): IDisposable { + this._composerFolderProviders.push(provider); + return toDisposable(() => { + const index = this._composerFolderProviders.indexOf(provider); + if (index >= 0) { + this._composerFolderProviders.splice(index, 1); + } + }); + } + + private get composers(): ChatWindowComposers { + if (!this._composers) { + this._composers = this._register(this._instantiationService.createInstance(ChatWindowComposers, (resource: URI) => this._waitForSession(resource))); + } + return this._composers; + } + + async openChat(session: ISession, chatResource: URI | undefined, options?: IAgentsChatPartOpenOptions): Promise { + const resource = chatResource ?? session.mainChat.get().resource; + const chat = this._findChat(resource) ?? this._addChat(resource, undefined); + await this._open(chat, options); + } + + async openComposer(target: INewSessionTarget | undefined, options?: IAgentsChatPartOpenOptions): Promise { + // A new session in the active tab while it is a composer that has not sent its session yet, as a chat window does + const active = this._activeInput.get(); + const activeChat = active && this._chats.get(active); + if (activeChat?.isComposer && !options?.sideBySide && !options?.newComposer) { + return this._open(activeChat, options); + } + const resource = createChatWindowComposerResource(); + const composer = this.composers.create(resource, { target, ...this.getComposerFolderOptions() }, session => this.openChat(session, undefined)); + await this._open(this._addChat(resource, composer), options); + } + + async closeChat(chatResource: URI): Promise { + const chat = this._findChat(chatResource); + if (chat) { + this._closeChat(chat); + } + } + + isShowing(chatResource: URI): boolean { + return !!this._findChat(chatResource); + } + + async closeIdleComposers(): Promise { + const closes: Promise[] = []; + for (const chat of [...this._chats.values()]) { + if (chat.isIdleComposer) { + const group = this._groupOf(chat.input); + if (group) { + closes.push(group.closeEditor(chat.input)); + } else { + this._chats.deleteAndDispose(chat.input); + } + } + } + await Promise.all(closes); + } + + hasComposer(): boolean { + for (const chat of this._chats.values()) { + if (chat.isComposer) { + return true; + } + } + return false; + } + + focus(): void { + this._part?.activeGroup.focus(); + } + + async show(target: INewSessionTarget | undefined, options?: { readonly preserveFocus?: boolean }): Promise { + await this.restore(); + if (!this._tabCount()) { + return this.openComposer(target, { preserveFocus: options?.preserveFocus }); + } + this._layoutService.setPartHidden(false, Parts.CHAT_PART); + if (!options?.preserveFocus) { + this.focus(); + } + } + + async getComposerWidget(): Promise { + const active = this._activeInput.get(); + const chat = active && this._chats.get(active); + if (!chat?.isComposer) { + return undefined; + } + // Its view, and the widget of its input, show once its editor renders + const widget = derived(reader => chat.view.read(reader)?.composerView.read(reader)?.chatWidget); + const cts = new CancellationTokenSource(); + try { + return await raceTimeout(waitForState(widget, widget => !!widget, undefined, cts.token).catch(() => undefined), COMPOSER_VIEW_TIMEOUT, () => cts.cancel()); + } finally { + cts.dispose(); + } + } + + async restore(): Promise { + // Once it shows chats opened since the window started, those of before are no longer the ones to show again + if (this._restored || this._part) { + return; + } + this._restored = true; + this._restoring = true; + try { + await this._restoreChats(); + } finally { + this._restoring = false; + } + } + + private async _restoreChats(): Promise { + const stored = this._readStored(); + const claimed: URI[] = []; + const chats: AgentsChatPartChat[] = []; + for (const value of stored?.chats ?? []) { + let resource: URI; + try { + resource = URI.parse(value); + } catch { + continue; + } + if (isChatWindowComposerResource(resource)) { + claimed.push(resource); + const restored = await this.composers.restore(resource, session => this.openChat(session, undefined), this.getComposerFolderOptions()); + if (restored?.kind === 'composer') { + chats.push(this._addChat(resource, restored.composer)); + } else if (restored?.kind === 'session') { + chats.push(this._findChat(restored.session.mainChat.get().resource) ?? this._addChat(restored.session.mainChat.get().resource, undefined)); + } + } else { + chats.push(this._findChat(resource) ?? this._addChat(resource, undefined)); + } + } + this.composers.didFinishRestore(claimed); + + if (!chats.length) { + // Nothing to show again: the layout's chat part, shown or not, is closed + this._layoutService.setPartHidden(true, Parts.CHAT_PART); + return; + } + + const part = this._ensurePart(); + const active = chats[Math.min(Math.max(stored?.active ?? 0, 0), chats.length - 1)]; + // Only the active tab shows its chat: the others create their views once they show. A group activates the first editor + // it opens, inactive or not, so the active tab opens last. + await this._editorService.openEditors(chats.map(chat => ({ editor: chat.input, options: { pinned: true, preserveFocus: true, inactive: true } })), part.activeGroup); + await this._editorService.openEditor(active.input, { pinned: true, preserveFocus: true }, part.activeGroup); + this.composers.didOpen(chats.map(chat => chat.chat.get())); + if (this._layoutService.isVisible(Parts.CHAT_PART)) { + this._log('restore'); + } + } + + /** How many chats the chat part shows in its tabs, shown or not. */ + private _tabCount(): number { + return this._part?.groups.reduce((count, group) => count + group.editors.filter(input => this._chats.has(input)).length, 0) ?? 0; + } + + private _log(action: AgentsChatPartAction): void { + logAgentsChatPartAction(this._telemetryService, action, this._tabCount()); + } + + private async _open(chat: AgentsChatPartChat, options: IAgentsChatPartOpenOptions | undefined): Promise { + const part = this._ensurePart(); + this._layoutService.setPartHidden(false, Parts.CHAT_PART); + const group = this._groupOf(chat.input) + ?? (options?.sideBySide && !part.activeGroup.isEmpty ? part.addGroup(part.activeGroup, GroupDirection.RIGHT) : part.activeGroup); + await this._editorService.openEditor(chat.input, { pinned: true, preserveFocus: options?.preserveFocus }, group); + this.composers.didOpen([chat.chat.get()]); + } + + private _findChat(resource: URI): AgentsChatPartChat | undefined { + for (const [, chat] of this._chats) { + if (isEqual(chat.chat.get(), resource)) { + return chat; + } + } + return undefined; + } + + private _groupOf(input: EditorInput): IEditorGroup | undefined { + return this._part?.groups.find(group => group.contains(input)); + } + + private _addChat(resource: URI, composer: ChatWindowComposerModel | undefined): AgentsChatPartChat { + const chat = this._instantiationService.createInstance(AgentsChatPartChat, resource, composer, constObservable(this._window), this._activeInput, (chat: AgentsChatPartChat) => this._closeChat(chat)); + this._chats.set(chat.input, chat); + // It stays in the chat part: its editor shows there + chat.input.setMoveRestriction({ + reason: localize('agentsChatPart.cannotMove', "A chat stays in the chat part."), + canMoveTo: group => !!this._part?.groups.some(candidate => candidate.id === group), + }); + // Its chat goes with its editor, once the editor's dispose is done: disposing the chat disposes the editor, and what + // it shows, which would otherwise happen within the editor's own dispose, such as as its session is removed meanwhile + Event.once(chat.input.onWillDispose)(() => { + const ending = this._chats.deleteAndLeak(chat.input); + if (ending) { + queueMicrotask(() => ending.dispose()); + } + }); + return chat; + } + + private _closeChat(chat: AgentsChatPartChat): void { + const group = this._groupOf(chat.input); + if (group) { + void group.closeEditor(chat.input).catch(onUnexpectedError); + } else { + this._chats.deleteAndDispose(chat.input); + } + } + + private _ensurePart(): IContentPart { + if (this._part) { + return this._part; + } + + this._partStore.add(registerSessionChatViewEditor()); + // The part fills the chat part, which lays it out + const container = $('.agents-chat-part'); + container.style.width = '100%'; + container.style.height = '100%'; + // The menus of its groups tell it apart from the editor area, such as its More Actions, which offer to close it + const scopedContextKeyService = this._contextKeyService.createScoped(container); + InAgentsChatPartContext.bindTo(scopedContextKeyService).set(true); + const part = this._part = this._editorGroupsService.createEditorPart({ + container, + label: localize('agentsChatPart.label', "Chat"), + services: new ServiceCollection([ISessionHost, this.mainWindowHost], [IContextKeyService, scopedContextKeyService]), + }); + this._partStore.add(this._frame.part.setContent(part)); + // It shows its chats as a chat window's chat part does: over the chat surface, with the same options of its tabs + const background = this._chatViewFactory.createChatBackground?.(container, this._instantiationService); + if (background) { + this._partStore.add(background); + } + const showTabs = observableConfigValue('workbench.editor.showTabs', undefined, this._configurationService); + this._partStore.add(enforceChatPartOptions(part, derived(reader => getChatWindowMainPartOptions(showTabs.read(reader))))); + // The part goes with the window; its chats are not dirty, so nothing moves back to the editor area + this._partStore.add(toDisposable(() => part.forceClose())); + // After the part, whose context is scoped in it + this._partStore.add(scopedContextKeyService); + + // The active tab is the window's active chat + const groupListeners = this._partStore.add(new DisposableMap()); + const updateActive = () => { + const active = part.activeGroup.activeEditor ?? undefined; + if (active !== this._activeInput.get()) { + transaction(tx => { + this._activeReason.set(this._restoring ? 'restore' : 'open', tx); + this._activeInput.set(active, tx); + }); + } + }; + const trackGroup = (group: IEditorGroup) => groupListeners.set(group, Event.any(group.onDidActiveEditorChange, group.onDidCloseEditor)(() => { + updateActive(); + this._hideIfEmpty(); + })); + part.groups.forEach(trackGroup); + this._partStore.add(part.onDidAddGroup(trackGroup)); + this._partStore.add(part.onDidRemoveGroup(group => groupListeners.deleteAndDispose(group))); + this._partStore.add(part.onDidChangeActiveGroup(updateActive)); + updateActive(); + + return part; + } + + /** Closing the last chat closes the chat part, which gives the editor area its width back. */ + private _hideIfEmpty(): void { + if (this._lifecycleService.willShutdown || !this._part || this._part.groups.some(group => !group.isEmpty)) { + return; + } + this._layoutService.setPartHidden(true, Parts.CHAT_PART); + } + + private _readStored(): IStoredChatPartEditors | undefined { + const raw = this._storageService.get(CHAT_PART_EDITORS_STORAGE_KEY, StorageScope.WORKSPACE); + if (!raw) { + return undefined; + } + try { + const value = JSON.parse(raw) as Partial; + return Array.isArray(value.chats) && value.chats.every(chat => typeof chat === 'string') ? { chats: value.chats, active: typeof value.active === 'number' ? value.active : 0 } : undefined; + } catch { + return undefined; + } + } + + private _save(): void { + // Not yet restored, it keeps what it stored + if (!this._part) { + return; + } + const inputs = this._part.groups.flatMap(group => group.editors).filter(input => this._chats.has(input)); + if (!inputs.length) { + this._storageService.remove(CHAT_PART_EDITORS_STORAGE_KEY, StorageScope.WORKSPACE); + return; + } + const value: IStoredChatPartEditors = { + chats: inputs.map(input => this._chats.get(input)!.chat.get().toString()), + active: Math.max(0, inputs.indexOf(this._activeInput.get() ?? inputs[0])), + }; + this._storageService.store(CHAT_PART_EDITORS_STORAGE_KEY, JSON.stringify(value), StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + + private async _waitForSession(resource: URI): Promise { + return (await waitForSessionChat(this._sessionsManagementService, resource, RESTORE_SESSION_TIMEOUT))?.session; + } + +} + diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartActions.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartActions.ts new file mode 100644 index 00000000000000..eb3f06c111f771 --- /dev/null +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartActions.ts @@ -0,0 +1,218 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { localize, localize2 } from '../../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IWorkbenchLayoutService, Parts } from '../../../../services/layout/browser/layoutService.js'; +import { ChatContextKeys } from '../../../chat/common/actions/chatContextKeys.js'; +import { CHAT_CATEGORY } from '../../../chat/browser/actions/chatActions.js'; +import { Menus, SessionHeaderMenus } from '../menus.js'; +import { InAgentsChatPartContext } from './agentsChatPartContext.js'; +import { IAgentsChatPartService } from './agentsChatPart.js'; +import { getEditorWindowComposerTarget } from '../editorWindow/editorWindowSessionsPresenter.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { getSessionPresentationWorkspace, ISession } from '../../../../services/sessions/common/session.js'; +import { INewSessionTarget } from '../parts/chatView.js'; +import { SessionItemIsMultiSelectionContext } from '../../common/contextkeys.js'; +import { ISessionSection, QUICK_CHATS_SECTION_ID, SessionItemContextMenuId, SessionSectionToolbarMenuId, SessionSectionTypeContext } from '../views/sessionsList.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { AGENT_HOST_ENABLED_CONTEXT_KEY } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; + +/** Whether the chat part of the editor window shows. */ +export const AgentsChatPartVisibleContext = new RawContextKey('agentsChatPartVisible', false, localize('agentsChatPartVisible', "Whether the chat part beside the editor area shows.")); + +/** + * Registers the actions of the chat part of an editor window with the Sessions view, until the returned disposable is + * disposed: a new session or a new chat without a workspace in a tab of its own, and focusing, toggling and closing the + * chat part. + */ +export function registerAgentsChatPartActions(): IDisposable { + const store = new DisposableStore(); + + store.add(registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.agentsView.newSession', + title: localize2('agentsView.newSession', "New Session"), + icon: Codicon.add, + category: CHAT_CATEGORY, + f1: true, + precondition: ChatContextKeys.enabled, + menu: [{ id: Menus.SidebarSessionsHeader, group: 'navigation', order: 0, when: ChatContextKeys.enabled }], + }); + } + async run(accessor: ServicesAccessor): Promise { + const chatPart = accessor.get(IAgentsChatPartService); + await chatPart.openComposer(getEditorWindowComposerTarget(accessor.get(IWorkspaceContextService), accessor.get(IEditorService))); + } + })); + + // A chat without a workspace, as the Agents window has it: from the Chats section of the Sessions list and the Command + // Palette, without the Agents window's keybinding, which an editor window has for its editors + const quickChatEnabled = ContextKeyExpr.and(ChatContextKeys.enabled, AGENT_HOST_ENABLED_CONTEXT_KEY); + store.add(registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.agentsView.newQuickChat', + title: localize2('agentsView.newQuickChat', "New Quick Chat"), + icon: Codicon.add, + category: CHAT_CATEGORY, + f1: true, + precondition: quickChatEnabled, + menu: [SessionSectionToolbarMenuId, Menus.SessionSectionContext].map(id => ({ + id, + group: 'navigation', + order: 0, + when: ContextKeyExpr.and(quickChatEnabled, ContextKeyExpr.equals(SessionSectionTypeContext.key, QUICK_CHATS_SECTION_ID)), + })), + }); + } + async run(accessor: ServicesAccessor): Promise { + // A target without a folder is a chat without a workspace + await accessor.get(IAgentsChatPartService).openComposer({}, { newComposer: true }); + } + })); + + store.add(registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.agentsView.focusChatPart', + title: localize2('agentsView.focusChatPart', "Focus Chat Part"), + category: CHAT_CATEGORY, + f1: true, + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, AgentsChatPartVisibleContext), + }); + } + run(accessor: ServicesAccessor): void { + accessor.get(IAgentsChatPartService).focus(); + } + })); + + store.add(registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.agentsView.closeChatPart', + title: localize2('agentsView.closeChatPart', "Close Chat Part"), + category: CHAT_CATEGORY, + f1: true, + precondition: AgentsChatPartVisibleContext, + // Last in the More Actions of the chat it shows, in a group of its own: of its session header, or of its group's + // tabs while it shows them + menu: [ + ...SessionHeaderMenus.map(id => ({ id, group: 'secondary/z_close', order: 1, when: ContextKeyExpr.and(ChatContextKeys.enabled, AgentsChatPartVisibleContext) })), + { id: MenuId.EditorTitle, group: 'z_close', order: 1, when: ContextKeyExpr.and(ChatContextKeys.enabled, AgentsChatPartVisibleContext, InAgentsChatPartContext) }, + ], + }); + } + run(accessor: ServicesAccessor): void { + accessor.get(IWorkbenchLayoutService).setPartHidden(true, Parts.CHAT_PART); + } + })); + + store.add(registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.agentsView.toggleChatPart', + title: { + ...localize2('agentsView.toggleChatPart', "Toggle Chat Part Visibility"), + mnemonicTitle: localize({ key: 'miAgentsViewChatPart', comment: ['&& denotes a mnemonic'] }, "C&&hat Part"), + }, + category: CHAT_CATEGORY, + f1: true, + precondition: ContextKeyExpr.or(ChatContextKeys.enabled, AgentsChatPartVisibleContext), + toggled: AgentsChatPartVisibleContext, + menu: [{ id: MenuId.MenubarAppearanceMenu, group: '2_workbench_layout', order: 6, when: ChatContextKeys.enabled }], + }); + } + async run(accessor: ServicesAccessor): Promise { + const chatPart = accessor.get(IAgentsChatPartService); + const layoutService = accessor.get(IWorkbenchLayoutService); + if (layoutService.isVisible(Parts.CHAT_PART)) { + layoutService.setPartHidden(true, Parts.CHAT_PART); + } else { + await chatPart.show(getEditorWindowComposerTarget(accessor.get(IWorkspaceContextService), accessor.get(IEditorService))); + } + } + })); + + return store; +} + +/** + * Registers the actions of the Sessions list of an editor window that start a new session in the chat part, until the + * returned disposable is disposed: in the worktree of a section of the Worktree grouping, and in the folder of a session. + */ +export function registerAgentsViewNewSessionActions(): IDisposable { + const store = new DisposableStore(); + + // A new session in the folder or worktree of a section of the Worktree grouping: not of its Other section + store.add(registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.agentsView.newSessionInWorktree', + title: localize2('agentsView.newSessionInWorktree', "New Session"), + icon: Codicon.plus, + precondition: ChatContextKeys.enabled, + menu: [SessionSectionToolbarMenuId, Menus.SessionSectionContext].map(id => ({ + id, + group: 'navigation', + order: 0, + when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals(SessionSectionTypeContext.key, 'worktree')), + })), + }); + } + async run(accessor: ServicesAccessor, section?: ISessionSection): Promise { + // Not in its Other section, of the sessions without a local checkout or worktree + if (!section?.id.startsWith('worktree:') || section.id === 'worktree:other') { + return; + } + // In the section's checkout or worktree, which its sessions work in, as its latest session, such as with the same agent + const latest = [...section.sessions].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()).at(0); + const folderUri = latest && getSessionFolder(latest); + if (!folderUri) { + return; + } + await accessor.get(IAgentsChatPartService).openComposer(getNewSessionTarget(folderUri, latest), { newComposer: true }); + } + })); + + // A new session in the folder or worktree of a session + store.add(registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.agentsView.newSessionInSameFolder', + title: localize2('agentsView.newSessionInSameFolder', "New Session in Same Folder"), + // For one session: its folder + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, SessionItemIsMultiSelectionContext.negate()), + menu: [{ id: SessionItemContextMenuId, group: '0_new', order: 0, when: ContextKeyExpr.and(ChatContextKeys.enabled, SessionItemIsMultiSelectionContext.negate()) }], + }); + } + async run(accessor: ServicesAccessor, context?: ISession | ISession[]): Promise { + const session = Array.isArray(context) ? (context.length === 1 ? context[0] : undefined) : context; + const folderUri = session && getSessionFolder(session); + if (session && folderUri) { + await accessor.get(IAgentsChatPartService).openComposer(getNewSessionTarget(folderUri, session), { newComposer: true }); + } + } + })); + + return store; +} + +/** The folder a session works in: its worktree or its folder. */ +function getSessionFolder(session: ISession): URI | undefined { + const folder = getSessionPresentationWorkspace(session, undefined)?.folders[0]; + return folder?.workingDirectory ?? folder?.root; +} + +/** A new session in `folderUri`, with the agent of `like`, such as the session it starts from. */ +function getNewSessionTarget(folderUri: URI, like: ISession | undefined): INewSessionTarget { + return like ? { folderUri, providerId: like.providerId, sessionTypeId: like.sessionType } : { folderUri }; +} diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContext.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContext.ts new file mode 100644 index 00000000000000..1c468ee8c4aeb1 --- /dev/null +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContext.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../../nls.js'; +import { ContextKeyExpr, ContextKeyExpression, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { RemoteNameContext } from '../../../../common/contextkeys.js'; +import { ChatEntitlementContextKeys } from '../../../../services/chat/common/chatEntitlementService.js'; +import { ChatConfiguration } from '../../../chat/common/constants.js'; + +/** + * Where the chat part can show chats of sessions in an editor window: where AI features and agents are enabled, as for the + * Agents window, in local windows. + */ +export const AgentsChatPartAvailable: ContextKeyExpression = ContextKeyExpr.and( + ChatEntitlementContextKeys.Setup.hidden.negate(), + ChatEntitlementContextKeys.Setup.disabledInWorkspace.negate(), + ContextKeyExpr.has(`config.${ChatConfiguration.AgentEnabled}`), + RemoteNameContext.isEqualTo(''), +)!; + +/** Whether a menu shows for the chat part beside the editor area, such as the More Actions of its editor groups. */ +export const InAgentsChatPartContext = new RawContextKey('inAgentsChatPart', false, localize('inAgentsChatPart', "Whether the context is the chat part beside the editor area.")); diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContribution.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContribution.ts new file mode 100644 index 00000000000000..49b31fe8e11fbc --- /dev/null +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContribution.ts @@ -0,0 +1,190 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { onUnexpectedError } from '../../../../../base/common/errors.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { autorun, derived, IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { localize } from '../../../../../nls.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; +import { createDecorator, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../../browser/editor.js'; +import { EditorExtensions, IEditorFactoryRegistry } from '../../../../common/editor.js'; +import { IWorkbenchLayoutService, Parts } from '../../../../services/layout/browser/layoutService.js'; +import { ILifecycleService, LifecyclePhase } from '../../../../services/lifecycle/common/lifecycle.js'; +import { IChatViewHostService } from '../../../chat/browser/chatViewHost.js'; +import { ChangesMultiDiffSourceResolver } from '../../../sessionChanges/browser/changesMultiDiffSourceResolver.js'; +import { SessionChangesEditor } from '../../../sessionChanges/browser/sessionChangesEditor.js'; +import { SessionChangesEditorInput, SessionChangesEditorSerializer } from '../../../sessionChanges/browser/sessionChangesEditorInput.js'; +import { IEditorWindowSessionsPresenter, IEditorWindowSessionsPresenterService } from '../editorWindow/editorWindowSessionsPresenter.js'; +import { IEditorWindowSessionsRuntime } from '../editorWindow/editorWindowSessionsRuntime.js'; +import { IAgentsChatPartService, restoreAgentsChatPart } from './agentsChatPart.js'; +import { AgentsChatPartVisibleContext, registerAgentsChatPartActions } from './agentsChatPartActions.js'; +import { IAgentsChatPartFrame } from './agentsChatPartFrame.js'; +import { AgentsChatViewHost } from './agentsChatViewHost.js'; + +/** How a feature shows chats in the chat part of an editor window (see {@link IEditorWindowChatPartService.enable}). */ +export interface IEditorWindowChatPartOptions { + /** The view the chat part docks beside, such as the view that lists the sessions whose chats it shows. */ + readonly anchorViewId: string; + /** Of the features that enable the chat part, the anchor of the one with the highest priority applies. */ + readonly anchorPriority: number; + /** + * Whether chats open in the chat part now. While they do, what would show the Chat view's chats shows them in the chat + * part, and the chat part restores the chats it showed as the window last closed. While they do not, the Chat view keeps + * its chats, and the chat part does not restore and hides, as the window gets ready and as this turns false. + */ + readonly enabled: IObservable; +} + +export const IEditorWindowChatPartService = createDecorator('editorWindowChatPartService'); + +/** + * The chat part of an editor window: it shows chats of sessions, each in a tab, beside the editor area (see + * `IAgentsChatPartService`). It does nothing until a feature enables it. + */ +export interface IEditorWindowChatPartService { + readonly _serviceBrand: undefined; + + /** Whether chats open in the chat part now: while one of the features that enabled it has it enabled. */ + readonly enabled: IObservable; + + /** + * Has the window show chats in the chat part, as `options` say, until the returned disposable is disposed. Call it as + * the window starts, such as from a contribution of `WorkbenchPhase.BlockStartup`: the chat part has to exist before the + * workbench creates its grid. Features that enable it together share the one chat part. + */ + enable(options: IEditorWindowChatPartOptions): IDisposable; +} + +export class EditorWindowChatPartService extends Disposable implements IEditorWindowChatPartService { + + declare readonly _serviceBrand: undefined; + + private readonly _owners = observableValue(this, []); + readonly enabled: IObservable = derived(this, reader => this._owners.read(reader).some(owner => owner.enabled.read(reader))); + + /** What the chat part does while a feature enabled it. */ + private readonly _active = this._register(new MutableDisposable()); + + constructor( + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IWorkbenchLayoutService private readonly _layoutService: IWorkbenchLayoutService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @ILifecycleService private readonly _lifecycleService: ILifecycleService, + @IChatViewHostService private readonly _chatViewHostService: IChatViewHostService, + @IEditorWindowSessionsPresenterService private readonly _presenterService: IEditorWindowSessionsPresenterService, + @IEditorWindowSessionsRuntime private readonly _sessionsRuntime: IEditorWindowSessionsRuntime, + @IAgentsChatPartFrame private readonly _frame: IAgentsChatPartFrame, + ) { + super(); + } + + enable(options: IEditorWindowChatPartOptions): IDisposable { + this._owners.set([...this._owners.get(), options], undefined); + this._active.value ??= this._activate(); + this._updateAnchor(); + return toDisposable(() => { + this._owners.set(this._owners.get().filter(candidate => candidate !== options), undefined); + if (!this._owners.get().length) { + this._active.clear(); + } else { + this._updateAnchor(); + } + }); + } + + private _updateAnchor(): void { + const anchor = [...this._owners.get()].sort((a, b) => b.anchorPriority - a.anchorPriority).at(0); + this._layoutService.setChatPartAnchor(anchor && { viewId: anchor.anchorViewId }); + } + + private _activate(): DisposableStore { + const store = new DisposableStore(); + store.add(toDisposable(() => this._layoutService.setChatPartAnchor(undefined))); + + // The part, before the workbench creates its grid: reading it creates the delayed frame + void this._frame.part; + + store.add(registerAgentsChatPartActions()); + + // The Changes editor that the pills of the chats open in the editor area, as the Agents window registers it + store.add(Registry.as(EditorExtensions.EditorPane).registerEditorPane( + EditorPaneDescriptor.create(SessionChangesEditor, SessionChangesEditor.ID, localize('sessionChangesEditor.label', "Changes")), + [new SyncDescriptor(SessionChangesEditorInput)] + )); + store.add(Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer(SessionChangesEditorInput.ID, SessionChangesEditorSerializer)); + // Its diffs, also of a Changes editor that the window restores + store.add(this._instantiationService.createInstance(ChangesMultiDiffSourceResolver)); + + const visible = AgentsChatPartVisibleContext.bindTo(this._contextKeyService); + store.add(this._layoutService.onDidChangePartVisibility(({ partId }) => { + if (partId === Parts.CHAT_PART) { + visible.set(this._layoutService.isVisible(Parts.CHAT_PART)); + } + })); + store.add(toDisposable(() => visible.reset())); + + // The sessions of its chats are known while it shows + store.add(this._sessionsRuntime.enable({ viewIds: [] })); + + // While chats open in it, it replaces the Chat view, and shows the chats of the window's sessions. The host and the + // chat part's content are created as the first request comes, not as the window starts. + store.add(autorun(reader => { + if (!this.enabled.read(reader)) { + return; + } + const host = this._instantiationService.createInstance(AgentsChatViewHost); + reader.store.add(this._chatViewHostService.registerHost(host)); + // Unregistered, what the host is still doing does not open the chat part + reader.store.add(toDisposable(() => host.deactivate())); + reader.store.add(this._presenterService.registerPresenter(this._createPresenter())); + })); + + // While chats do not open in it, it does not show, also not as the window restored it shown + void this._lifecycleService.when(LifecyclePhase.Ready).then(() => { + if (store.isDisposed) { + return; + } + store.add(autorun(reader => { + if (!this.enabled.read(reader) && this._layoutService.isVisible(Parts.CHAT_PART)) { + this._layoutService.setPartHidden(true, Parts.CHAT_PART); + } + })); + }); + + void this._lifecycleService.when(LifecyclePhase.Restored).then(() => { + if (store.isDisposed) { + return; + } + const restored = this.enabled.get() ? restoreAgentsChatPart(this._layoutService, this._contextKeyService, () => this._chatPart()) : undefined; + visible.set(this._layoutService.isVisible(Parts.CHAT_PART)); + return restored; + }).catch(onUnexpectedError); + + return store; + } + + private _chatPart(): IAgentsChatPartService { + return this._instantiationService.invokeFunction(accessor => accessor.get(IAgentsChatPartService)); + } + + /** The chat part as the presenter of the window's sessions; what reads it without needing it does not create it. */ + private _createPresenter(): IEditorWindowSessionsPresenter { + const created = this._frame.contentCreated; + const existing = () => created.get() ? this._chatPart() : undefined; + return { + activeChat: derived(reader => created.read(reader) ? this._chatPart().activeChat.read(reader) : undefined), + openChat: (session, chatResource, options) => this._chatPart().openChat(session, chatResource, options), + openComposer: (target, options) => this._chatPart().openComposer(target, options), + closeChat: async chatResource => existing()?.closeChat(chatResource), + isShowing: chatResource => !!existing()?.isShowing(chatResource), + hasComposer: () => !!existing()?.hasComposer(), + closeIdleComposers: async () => existing()?.closeIdleComposers(), + focus: () => this._chatPart().focus(), + }; + } +} diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartFrame.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartFrame.ts new file mode 100644 index 00000000000000..4d84d553d7781b --- /dev/null +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartFrame.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { createDecorator, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ChatHostPart } from '../../../../browser/parts/chat/chatHostPart.js'; + +export const IAgentsChatPartFrame = createDecorator('agentsChatPartFrame'); + +/** + * The chat part of an editor window, as a part of the workbench grid: created as a feature enables the chat part as the + * window starts, before the grid is, so that the grid has a place for it. What it shows is created once a chat opens in it. + */ +export interface IAgentsChatPartFrame { + readonly _serviceBrand: undefined; + readonly part: ChatHostPart; + /** + * Whether what the chat part shows, `IAgentsChatPartService`, was created: what reads it without needing it, such as + * what the window's chat is, reads it only then, rather than creating it. + */ + readonly contentCreated: IObservable; + /** Called as `IAgentsChatPartService` is created. */ + didCreateContent(): void; +} + +export class AgentsChatPartFrame extends Disposable implements IAgentsChatPartFrame { + + declare readonly _serviceBrand: undefined; + + readonly part: ChatHostPart; + private readonly _contentCreated = observableValue(this, false); + readonly contentCreated: IObservable = this._contentCreated; + + constructor(@IInstantiationService instantiationService: IInstantiationService) { + super(); + this.part = this._register(instantiationService.createInstance(ChatHostPart)); + } + + didCreateContent(): void { + this._contentCreated.set(true, undefined); + } +} diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartTelemetry.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartTelemetry.ts new file mode 100644 index 00000000000000..8b7d4ab248d691 --- /dev/null +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartTelemetry.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; + +export type AgentsChatPartAction = 'open' | 'close' | 'restore'; + +type AgentsChatPartEvent = { + action: AgentsChatPartAction; + tabCount: number; +}; + +type AgentsChatPartClassification = { + action: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'What happened: the chat part opened, closed or restored after a reload.' }; + tabCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'How many chat tabs the chat part has after the action.' }; + owner: 'sandy081'; + comment: 'Tracks how the chat part beside the editor area of an editor window with the Sessions view is used.'; +}; + +export function logAgentsChatPartAction(telemetryService: ITelemetryService, action: AgentsChatPartAction, tabCount: number): void { + telemetryService.publicLog2('agentsView.chatPart', { action, tabCount }); +} diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts new file mode 100644 index 00000000000000..e3f09b443968cc --- /dev/null +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts @@ -0,0 +1,217 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { raceTimeout } from '../../../../../base/common/async.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { CancellationError, onUnexpectedError } from '../../../../../base/common/errors.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { derived, IObservable } from '../../../../../base/common/observable.js'; +import { isEqual } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IWorkbenchLayoutService, Parts } from '../../../../services/layout/browser/layoutService.js'; +import { IChatWidget, IChatWidgetService } from '../../../chat/browser/chat.js'; +import { IChatViewHost, IChatViewHostOpenOptions, IChatViewHostSessionStart, IChatViewHostSessionStartOptions, IChatViewHostShownSession } from '../../../chat/browser/chatViewHost.js'; +import { IChatService } from '../../../chat/common/chatService/chatService.js'; +import { isAgentHostSessionResource } from '../../../chat/common/chatSessionsService.js'; +import { ChatAgentLocation } from '../../../chat/common/constants.js'; +import { isUntitledChatSession } from '../../../chat/common/model/chatUri.js'; +import { INewSessionTarget } from '../parts/chatView.js'; +import { ISessionsManagementService } from '../../common/sessionsManagement.js'; +import { IAgentsChatPartService } from './agentsChatPart.js'; +import { getEditorWindowComposerTarget } from '../editorWindow/editorWindowSessionsPresenter.js'; +import { AgentsChatPartAvailable } from './agentsChatPartContext.js'; +import { IAgentsChatPartFrame } from './agentsChatPartFrame.js'; +import { IAgentsViewProvidersService, waitForAgentsViewSession } from './agentsViewProviders.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; + +/** How long it waits for the widget of a chat it shows to be created. */ +const WAIT_TIMEOUT = 5_000; + +/** + * The host of the Chat view's chats in an editor window whose chat part replaces the Chat view: it shows in the chat part + * the window's active chat, a new-session composer, or the chat of a session. It shows the chats of sessions only: it + * leaves local chat and the chats of other kinds to the Chat view (see {@link canShow}). + */ +export class AgentsChatViewHost implements IChatViewHost { + + constructor( + @IAgentsChatPartService private readonly _chatPart: IAgentsChatPartService, + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, + @IWorkbenchLayoutService private readonly _layoutService: IWorkbenchLayoutService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IAgentsViewProvidersService private readonly _providers: IAgentsViewProvidersService, + @IEditorService private readonly _editorService: IEditorService, + @IChatService private readonly _chatService: IChatService, + @INotificationService private readonly _notificationService: INotificationService, + @IAgentsChatPartFrame frame: IAgentsChatPartFrame, + ) { + // Read once the chat part exists: reading it does not create it + this.shownSession = derived(reader => frame.contentCreated.read(reader) ? this._chatPart.shownSession.read(reader) : undefined); + } + + /** The chat of the chat part's active tab, and whether the user opened it or the chat part restored it. */ + readonly shownSession: IObservable; + + /** Increases as the host is deactivated: what started before then does not open the chat part once it continues. */ + private _generation = 0; + + /** + * It no longer replaces the Chat view, such as once chat needs setting up: what it is still doing, such as looking up a + * session or starting one, does not open the chat part. + */ + deactivate(): void { + this._generation++; + } + + /** The chats of sessions of the agent host, and those of the window's sessions; not local chat, nor chats of other kinds. */ + canShow(sessionResource: URI): boolean { + return isAgentHostSessionResource(sessionResource) || !!this._sessionsManagementService.getSessionForChatResource(sessionResource); + } + + /** New Chat, such as + in the title of the Chat view, opens a new-session composer in the chat part. */ + newChat(): boolean { + if (!this._available) { + return false; + } + this._chatPart.openComposer(this._composerTarget(), { newComposer: true }).catch(onUnexpectedError); + return true; + } + + /** Whether it shows anything: not while chat is hidden or disabled, such as with AI features off. */ + private get _available(): boolean { + return this._contextKeyService.contextMatchesRules(AgentsChatPartAvailable); + } + + async reveal(preserveFocus?: boolean): Promise { + if (!this._available) { + return undefined; + } + await this._chatPart.show(this._composerTarget(), { preserveFocus }); + const active = this._chatPart.activeChat.get(); + return active ? this._waitForWidget(active.chat.resource) : this._chatPart.getComposerWidget(); + } + + async revealIfShowing(sessionResource: URI, options?: IChatViewHostOpenOptions): Promise { + return this._chatPart.isShowing(sessionResource) ? this.openSession(sessionResource, options) : undefined; + } + + async openSession(sessionResource: URI, options?: IChatViewHostOpenOptions): Promise { + if (!this._available) { + return undefined; + } + // Its session is known only while the providers are held: until it shows in the chat part, which holds them then + const generation = this._generation; + const hold = this._providers.acquire(); + try { + const owner = await waitForAgentsViewSession(this._sessionsManagementService, sessionResource, () => this._sessionsManagementService.getSessionForChatResource(sessionResource)); + if (!owner || generation !== this._generation) { + return undefined; + } + await this._chatPart.openChat(owner.session, owner.chat.resource, { preserveFocus: options?.preserveFocus }); + return await this._waitForWidget(owner.chat.resource); + } finally { + hold.dispose(); + } + } + + async toggle(): Promise { + if (this._layoutService.isVisible(Parts.CHAT_PART)) { + this._layoutService.setPartHidden(true, Parts.CHAT_PART); + return false; + } + if (!this._available) { + return false; + } + await this._chatPart.show(this._composerTarget()); + return true; + } + + /** + * Holds the session's model while its first request, if any, is sent, and shows the session in the chat part only once it + * runs, became real or was given: a new session that did not run is not listed, so a new-session composer of its type + * shows instead, where a request that did not run can be put back. + */ + async startSession(sessionResource: URI, options: IChatViewHostSessionStartOptions): Promise { + if (options.local) { + // It shows sessions, not local chat + this._notificationService.info(localize('agentsChatViewHost.localChatUnavailable', "Local chat sessions are not available while chats open in the chat part.")); + throw new CancellationError(); + } + const model = await this._chatService.acquireOrLoadSession(sessionResource, ChatAgentLocation.Chat, CancellationToken.None, 'AgentsChatViewHost#startSession', 'explicitOverride'); + if (!model) { + throw new Error(`Unable to load the chat session ${sessionResource.toString()}`); + } + const sessionType = sessionResource.scheme; + const generation = this._generation; + let composer: Promise | undefined; + return { + moved: async () => { }, + complete: async (resource, sent) => { + if (generation !== this._generation) { + // Deactivated meanwhile: it shows nothing, neither the session nor a composer + return false; + } + const given = !isUntitledChatSession(sessionResource); + const shown = (sent || given || !isEqual(resource, sessionResource)) && !!await this.openSession(resource); + if (!shown) { + // Shown before the start completes, and given back by createComposer + composer = this._openComposer(sessionType); + await composer; + } + return shown; + }, + createComposer: () => composer ?? (generation === this._generation ? this._openComposer(sessionType) : Promise.resolve(undefined)), + dispose: () => model.dispose(), + }; + } + + /** Shows a new-session composer of `sessionType` and resolves to the widget of its input, whose submits create the session. */ + private async _openComposer(sessionType: string): Promise { + if (!this._available) { + return undefined; + } + // A composer of its own: the one it shows may aim at sessions of another type + await this._chatPart.openComposer({ ...this._composerTarget(), sessionTypeId: sessionType }, { newComposer: true }); + return this._chatPart.getComposerWidget(); + } + + private _composerTarget(): INewSessionTarget | undefined { + return getEditorWindowComposerTarget(this._workspaceContextService, this._editorService); + } + + /** The widget of the chat of `resource`, once the chat part's view of the chat creates it. */ + private async _waitForWidget(resource: URI): Promise { + const find = () => this._chatWidgetService.getAllWidgets().find(widget => isEqual(widget.viewModel?.sessionResource, resource)); + const found = find(); + if (found) { + return found; + } + const store = new DisposableStore(); + try { + return await raceTimeout(new Promise(resolve => { + const check = () => { + const widget = find(); + if (widget) { + resolve(widget); + } + }; + const watch = (widget: IChatWidget) => store.add(widget.onDidChangeViewModel(check)); + this._chatWidgetService.getAllWidgets().forEach(watch); + store.add(this._chatWidgetService.onDidAddWidget(widget => { + watch(widget); + check(); + })); + }), WAIT_TIMEOUT); + } finally { + store.dispose(); + } + } +} diff --git a/src/vs/workbench/contrib/sessions/browser/chatEditor/chatEditorContent.ts b/src/vs/workbench/contrib/sessions/browser/chatEditor/chatEditorContent.ts index b4a71aea2acfa2..9d5d46157bc143 100644 --- a/src/vs/workbench/contrib/sessions/browser/chatEditor/chatEditorContent.ts +++ b/src/vs/workbench/contrib/sessions/browser/chatEditor/chatEditorContent.ts @@ -59,6 +59,7 @@ export class ChatEditorContent extends Disposable { private readonly _contentContainer: HTMLElement; /** The view of its composer, or of the request the composer sent, while it shows instead of the chat. */ private readonly _composerView = observableValue(this, undefined); + readonly composerView: IObservable = this._composerView; /** * The chat model of a composer's session whose chat it shows, kept loaded from the composer until the chat's view has * bound to it: only that handoff, as a model held longer cannot be released for its view to load it again, such as diff --git a/src/vs/workbench/contrib/sessions/browser/chatEditor/sessionChatEditorHost.ts b/src/vs/workbench/contrib/sessions/browser/chatEditor/sessionChatEditorHost.ts index 10c19ee29cb442..b255c98c92b283 100644 --- a/src/vs/workbench/contrib/sessions/browser/chatEditor/sessionChatEditorHost.ts +++ b/src/vs/workbench/contrib/sessions/browser/chatEditor/sessionChatEditorHost.ts @@ -131,6 +131,8 @@ export abstract class SessionChatEditorEntry extends Disposable implements ISess get isComposer(): boolean { return !!this._composer.get(); } /** Whether it is a new-session composer whose closing loses nothing (see {@link ChatWindowComposerModel.isPristine}). */ get isPristineComposer(): boolean { return !!this._composer.get()?.isPristine(); } + /** Whether it is a new-session composer with nothing typed or attached, and no send of it under way. */ + get isIdleComposer(): boolean { return !!this._composer.get()?.isIdle(); } /** The custom group of the sessions list that the sessions of its new-session composer join, while it is one. */ get composerGroupId(): string | undefined { return this._composer.get()?.groupId; } diff --git a/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessions.contribution.ts b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessions.contribution.ts index d797da4bdfe5f3..e0f2b0c89fbe0e 100644 --- a/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessions.contribution.ts +++ b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessions.contribution.ts @@ -66,6 +66,9 @@ import { EditorWindowSessionsService } from '../agentsView/editorWindowSessionsS import { EditorWindowSessionsNavigationHost } from '../agentsView/editorWindowSessionsNavigationHost.js'; import { EditorWindowSessionsPresenterService, IEditorWindowSessionsPresenterService } from './editorWindowSessionsPresenter.js'; import { EditorWindowSessionsRuntime, IEditorWindowSessionsRuntime } from './editorWindowSessionsRuntime.js'; +import { AgentsChatPartFrame, IAgentsChatPartFrame } from '../agentsView/agentsChatPartFrame.js'; +import { AgentsChatPartService, IAgentsChatPartService } from '../agentsView/agentsChatPart.js'; +import { EditorWindowChatPartService, IEditorWindowChatPartService } from '../agentsView/agentsChatPartContribution.js'; // The sessions services of an editor window, for the sessions features it can host. Every one is created only as it is // first used, and nothing in the window uses them until such a feature does. @@ -124,3 +127,8 @@ registerSingleton(ISessionsNavigationService, EditorWindowSessionsNavigationHost registerSingleton(IEditorWindowSessionsPresenterService, EditorWindowSessionsPresenterService, InstantiationType.Delayed); // Holds the sessions providers for the features that enable it, while what they show shows registerSingleton(IEditorWindowSessionsRuntime, EditorWindowSessionsRuntime, InstantiationType.Delayed); + +// The chat part, which shows chats of sessions in tabs beside the editor area, once a feature enables it +registerSingleton(IEditorWindowChatPartService, EditorWindowChatPartService, InstantiationType.Delayed); +registerSingleton(IAgentsChatPartFrame, AgentsChatPartFrame, InstantiationType.Delayed); +registerSingleton(IAgentsChatPartService, AgentsChatPartService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/sessions/providers/agentsView/test/browser/agentsViewServices.test.ts b/src/vs/workbench/contrib/sessions/providers/agentsView/test/browser/agentsViewServices.test.ts index e6e9d04d833b65..cb0eabe64bb956 100644 --- a/src/vs/workbench/contrib/sessions/providers/agentsView/test/browser/agentsViewServices.test.ts +++ b/src/vs/workbench/contrib/sessions/providers/agentsView/test/browser/agentsViewServices.test.ts @@ -9,6 +9,8 @@ import { getSingletonServiceDescriptors } from '../../../../../../../platform/in import { _util, ServiceIdentifier } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { IContextKeyService } from '../../../../../../../platform/contextkey/common/contextkey.js'; import { EditorWindowMainSessionHost } from '../../../../browser/agentsView/editorWindowMainSessionHost.js'; +import { AgentsChatViewHost } from '../../../../browser/agentsView/agentsChatViewHost.js'; +import { EditorWindowChatPartService } from '../../../../browser/agentsView/agentsChatPartContribution.js'; import { ChatView, NewChatView } from '../../../../browser/chat/chatView.js'; import { NewChatInSessionWidget } from '../../../../browser/chat/newChatInSessionWidget.js'; import { NewChatWidget } from '../../../../browser/chat/newChatWidget.js'; @@ -62,7 +64,7 @@ suite('Editor window sessions services', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('an editor window registers every sessions service that its sessions list and chats depend on', () => { + test('an editor window registers every sessions service that its sessions list, chats and chat part depend on', () => { const registered = new Map(getSingletonServiceDescriptors().map(([id, descriptor]) => [id.toString(), descriptor])); const missing = new Set(); const seen = new Set(); @@ -84,7 +86,7 @@ suite('Editor window sessions services', () => { // The views, and what they create as they show a session, such as a chat's input toolbar: their services are checked // as they are created, not as the views that create them are const roots = [ - SessionsView, SessionsList, ChatView, ChatGroupView, ChatEditorView, ChatEditorContent, NewChatWidget, NewChatInSessionWidget, SharedChatInputComposer, + SessionsView, SessionsList, ChatView, ChatGroupView, ChatEditorView, ChatEditorContent, NewChatWidget, NewChatInSessionWidget, SharedChatInputComposer, AgentsChatViewHost, EditorWindowChatPartService, NewChatView, SessionChatInputToolbar, SessionInputBanners, SessionArchiveNudge, SessionStatusIcon, ResponseSelectionSideChatController, SessionHeader, ChatCompositeBar, ChatHeader, ChatViewSessionActivityBar, RemoteHostUnavailableEmptyState, SessionReadOnlyBanner, SessionRemoteConnection, SessionsListNotification, SessionsInboxScope, SessionStorageCleanupNotice, SessionsListFilters, diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts new file mode 100644 index 00000000000000..a32dc9ad01c464 --- /dev/null +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts @@ -0,0 +1,638 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Emitter, Event } from '../../../../../../base/common/event.js'; +import { DisposableStore, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { constObservable, IObservable, observableValue, transaction } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IContextKeyChangeEvent, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IEditorOptions } from '../../../../../../platform/editor/common/editor.js'; +import { MockContextKeyService } from '../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { IStorageService, StorageScope, StorageTarget, WillSaveStateReason } from '../../../../../../platform/storage/common/storage.js'; +import { EditorInputWithOptions } from '../../../../../common/editor.js'; +import { EditorInput } from '../../../../../common/editor/editorInput.js'; +import { IContentPart, IEditorGroupsService } from '../../../../../services/editor/common/editorGroupsService.js'; +import { IWorkspace, IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { IEditorService, PreferredGroup } from '../../../../../services/editor/common/editorService.js'; +import { IPartVisibilityChangeEvent, IWorkbenchLayoutService, Parts } from '../../../../../services/layout/browser/layoutService.js'; +import { ISessionHost } from '../../../../../services/sessions/browser/sessionHost.js'; +import { IChat, ISession, SessionStatus } from '../../../../../services/sessions/common/session.js'; +import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; +import { ChatContextKeys } from '../../../../chat/common/actions/chatContextKeys.js'; +import { IChatService } from '../../../../chat/common/chatService/chatService.js'; +import { AgentsChatPartService, IAgentsChatPartService, restoreAgentsChatPart } from '../../../browser/agentsView/agentsChatPart.js'; +import { IAgentsChatPartFrame } from '../../../browser/agentsView/agentsChatPartFrame.js'; +import { EditorWindowMainSessionHost } from '../../../browser/agentsView/editorWindowMainSessionHost.js'; +import { ChatEditorInput } from '../../../browser/chatEditor/chatEditor.js'; +import { isChatWindowComposerResource } from '../../../browser/chatEditor/chatWindowComposer.js'; +import { IChatViewFactory } from '../../../browser/chatViewFactory.js'; +import { ISessionGroupsService } from '../../../browser/sessionGroupsService.js'; +import { INewSessionDraftSlot, ISessionChange, ISessionsManagementService } from '../../../common/sessionsManagement.js'; +import { isEqual } from '../../../../../../base/common/resources.js'; +import { buildTestSession } from '../../common/testSessionBuilder.js'; +import { ISessionsListModelService } from '../../../browser/sessionsListModelService.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { timeout } from '../../../../../../base/common/async.js'; + +/** An editor group that keeps its editors in memory, as the chat part's groups do, without rendering them. */ +class FakeGroup { + private static nextId = 1; + readonly id = FakeGroup.nextId++; + editors: EditorInput[] = []; + activeEditor: EditorInput | null = null; + readonly onDidActiveEditorChangeEmitter = new Emitter(); + readonly onDidActiveEditorChange = this.onDidActiveEditorChangeEmitter.event; + readonly onDidCloseEditorEmitter = new Emitter(); + readonly onDidCloseEditor = this.onDidCloseEditorEmitter.event; + get isEmpty() { return this.editors.length === 0; } + contains(editor: EditorInput) { return this.editors.includes(editor); } + /** Opens `editor`, which becomes active unless it opens inactive in a group that has an active editor, as in a real group. */ + open(editor: EditorInput, inactive?: boolean): void { + if (!this.editors.includes(editor)) { + this.editors.push(editor); + } + if (!inactive || !this.activeEditor) { + this.activeEditor = editor; + this.onDidActiveEditorChangeEmitter.fire(); + } + } + async closeEditor(editor: EditorInput): Promise { + this.editors = this.editors.filter(candidate => candidate !== editor); + if (this.activeEditor === editor) { + this.activeEditor = this.editors.at(-1) ?? null; + } + this.onDidCloseEditorEmitter.fire(); + this.onDidActiveEditorChangeEmitter.fire(); + editor.dispose(); + return true; + } + focus(): void { } + dispose(): void { + this.onDidActiveEditorChangeEmitter.dispose(); + this.onDidCloseEditorEmitter.dispose(); + } +} + +/** The editor part of the chat part, as the chat part creates it: it starts with one group and adds more beside it. */ +class FakePart { + readonly groups: FakeGroup[] = []; + activeGroup: FakeGroup; + /** The options that the chat part enforces on it. */ + options: unknown; + readonly element = document.createElement('div'); + private readonly onDidAddGroupEmitter = new Emitter(); + readonly onDidAddGroup = this.onDidAddGroupEmitter.event; + private readonly onDidChangeActiveGroupEmitter = new Emitter(); + readonly onDidChangeActiveGroup = this.onDidChangeActiveGroupEmitter.event; + readonly onDidRemoveGroup = Event.None; + readonly onDidChange = Event.None; + + constructor() { + this.activeGroup = this.addGroup(); + } + + addGroup(): FakeGroup { + const group = new FakeGroup(); + this.groups.push(group); + this.onDidAddGroupEmitter.fire(group); + return group; + } + activate(group: FakeGroup): void { + if (this.activeGroup !== group) { + this.activeGroup = group; + this.onDidChangeActiveGroupEmitter.fire(group); + } + } + enforcePartOptions(options: unknown): IDisposable { + this.options = options; + return toDisposable(() => { }); + } + setVisible(): void { } + forceClose(): void { } + dispose(): void { + this.groups.forEach(group => group.dispose()); + this.onDidAddGroupEmitter.dispose(); + this.onDidChangeActiveGroupEmitter.dispose(); + } +} + +/** A context key service whose keys change as a test sets them, such as chat turning off. */ +class ChangingContextKeyService extends MockContextKeyService { + private readonly onDidChangeContextEmitter = new Emitter(); + override get onDidChangeContext() { return this.onDidChangeContextEmitter.event; } + setValue(key: string, value: boolean): void { + this.createKey(key, value); + this.onDidChangeContextEmitter.fire(upcastPartial({ affectsSome: keys => keys.has(key) })); + } + override dispose(): void { + this.onDidChangeContextEmitter.dispose(); + } +} + +const CHAT_PART_EDITORS_STORAGE_KEY = 'workbench.chatPart.editors'; +const session = upcastPartial({}); +const chatA = URI.parse('test:/a'); +const chatB = URI.parse('test:/b'); +const chatC = URI.parse('test:/c'); + +suite('AgentsChatPartService', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createService(options?: { readonly stored?: string; readonly visible?: boolean; readonly chatEnabled?: boolean; readonly sessions?: () => readonly ISession[]; readonly newSession?: IObservable }) { + const store = disposables.add(new DisposableStore()); + const instantiationService = workbenchInstantiationService(undefined, store); + const contextKeyService = store.add(new ChangingContextKeyService()); + if (options?.chatEnabled !== undefined) { + contextKeyService.createKey(ChatContextKeys.enabled.key, options.chatEnabled); + } + instantiationService.stub(IContextKeyService, contextKeyService); + const storageService = instantiationService.get(IStorageService) as TestStorageService; + if (options?.stored !== undefined) { + storageService.store(CHAT_PART_EDITORS_STORAGE_KEY, options.stored, StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + const configurationService = instantiationService.get(IConfigurationService) as TestConfigurationService; + + // The chat part, and whether the layout shows it + const part = new FakePart(); + store.add(part); + let partVisible = !!options?.visible; + const onDidChangePartVisibility = store.add(new Emitter()); + instantiationService.stub(IWorkbenchLayoutService, upcastPartial({ + onDidChangePartVisibility: onDidChangePartVisibility.event, + isVisible: (candidate: Parts) => candidate === Parts.CHAT_PART && partVisible, + setPartHidden: (hidden: boolean, candidate: Parts) => { + if (candidate === Parts.CHAT_PART && partVisible === hidden) { + partVisible = !hidden; + onDidChangePartVisibility.fire({ partId: Parts.CHAT_PART, visible: partVisible }); + } + }, + hasFocus: () => false, + })); + instantiationService.stub(IAgentsChatPartFrame, upcastPartial({ part: upcastPartial({ setContent: () => toDisposable(() => { }) }), didCreateContent: () => { } })); + instantiationService.stub(IEditorGroupsService, upcastPartial({ createEditorPart: () => part as unknown as IContentPart, mainPart: undefined })); + instantiationService.stub(IEditorService, upcastPartial({ + openEditor: (async (editor: EditorInput, editorOptions: IEditorOptions | undefined, group: FakeGroup) => { + group.open(editor, editorOptions?.inactive); + if (!editorOptions?.inactive) { + part.activate(group); + } + return undefined; + }) as IEditorService['openEditor'], + openEditors: async (editors: EditorInputWithOptions[], group?: PreferredGroup) => { + const target = group as unknown as FakeGroup; + editors.forEach(({ editor, options: editorOptions }) => target.open(editor, editorOptions?.inactive)); + return []; + }, + })); + instantiationService.stub(IChatService, upcastPartial({ acquireExistingSession: () => undefined })); + instantiationService.stub(IChatViewFactory, upcastPartial({})); + instantiationService.stub(IWorkspaceContextService, upcastPartial({ onDidChangeWorkspaceFolders: Event.None, getWorkspace: () => upcastPartial({ folders: [] }) })); + + // The sessions, of which each composer holds a slot for the session it creates; the listeners on their events count + // The changes of the listed sessions, and of each session, also of those the providers' listing scope hides + const toSessionChanges = (added: readonly ISession[], removed: readonly ISession[]): readonly ISessionChange[] => [...added.map(session => ({ session, kind: 'added' as const })), ...removed.map(session => ({ session, kind: 'removed' as const }))]; + const sessionsChanged = store.add(new Emitter()); + const allSessionsChanged = store.add(new Emitter()); + let sessionListeners = 0; + const counted = (emitter: Emitter): Event => (listener, thisArgs, listenerStore) => { + sessionListeners++; + const subscription = emitter.event(listener, thisArgs); + const countedSubscription = toDisposable(() => { sessionListeners--; subscription.dispose(); }); + if (listenerStore instanceof DisposableStore) { + listenerStore.add(countedSubscription); + } else if (Array.isArray(listenerStore)) { + listenerStore.push(countedSubscription); + } + return countedSubscription; + }; + let liveSlots = 0; + let nextSlot = 0; + instantiationService.stub(ISessionsManagementService, upcastPartial({ + onDidChangeSessions: counted(sessionsChanged), + onDidChangeSession: counted(allSessionsChanged), + onDidAcceptNewSessionRequest: Event.None, + onDidReplaceSession: Event.None, + onDidReplaceNewDraftSession: Event.None, + getSessions: () => [], + getSession: resource => options?.sessions?.().find(candidate => isEqual(candidate.resource, resource)), + getSessionForChatResource: resource => { + for (const candidate of options?.sessions?.() ?? []) { + const chat = candidate.chats.get().find(chat => isEqual(chat.resource, resource)); + if (chat) { + return { session: candidate, chat }; + } + } + return undefined; + }, + createNewSessionSlot: () => { + liveSlots++; + return upcastPartial({ id: `slot-${nextSlot++}`, newSession: options?.newSession ?? constObservable(undefined), dispose: () => { liveSlots--; } }); + }, + })); + instantiationService.stub(ISessionsListModelService, upcastPartial({ getStatusIcon: () => Codicon.circle })); + instantiationService.stub(ISessionGroupsService, upcastPartial({ setDraftSlotGroup: () => toDisposable(() => { }), getGroupOfSession: () => undefined })); + + const host = new EditorWindowMainSessionHost(contextKeyService); + instantiationService.stub(ISessionHost, host); + const service = store.add(instantiationService.createInstance(AgentsChatPartService)); + const name = (input: EditorInput | null) => input instanceof ChatEditorInput ? (isChatWindowComposerResource(input.chatResource) ? 'composer' : input.chatResource.path) : null; + return { + service, + part, + hide: () => instantiationService.get(IWorkbenchLayoutService).setPartHidden(true, Parts.CHAT_PART), + save: () => { + storageService.testEmitWillSaveState(WillSaveStateReason.NONE); + return storageService.get(CHAT_PART_EDITORS_STORAGE_KEY, StorageScope.WORKSPACE); + }, + setShowTabs: (value: string) => { + configurationService.setUserConfiguration('workbench.editor.showTabs', value); + configurationService.onDidChangeConfigurationEmitter.fire(upcastPartial({ affectsConfiguration: (key: string) => key === 'workbench.editor.showTabs' })); + }, + setChatEnabled: (enabled: boolean) => contextKeyService.setValue(ChatContextKeys.enabled.key, enabled), + fireSessionsChanged: (added: readonly ISession[], removed: readonly ISession[] = []) => { + sessionsChanged.fire({ added, removed, changed: [] } as never); + allSessionsChanged.fire(toSessionChanges(added, removed) as never); + }, + /** Changes of sessions that the providers' listing scope hides, such as of another folder: they are not listed. */ + fireHiddenSessionsChanged: (added: readonly ISession[], removed: readonly ISession[] = []) => allSessionsChanged.fire(toSessionChanges(added, removed) as never), + state: () => ({ tabs: part.groups.reduce((count, group) => count + group.editors.length, 0), liveSlots, sessionListeners, visible: partVisible }), + /** The tabs of each group, the active one of each, and whether the layout shows the chat part. */ + layout: () => ({ + groups: part.groups.map(group => ({ tabs: group.editors.map(name), active: name(group.activeEditor) })), + activeGroup: part.groups.indexOf(part.activeGroup), + visible: partVisible, + }), + }; + } + + test('a new session reuses the active composer that has not sent its session, so opening and closing the chat part does not add tabs', async () => { + const { service, hide, state } = createService(); + + await service.openComposer(undefined); + const afterFirst = state(); + for (let index = 0; index < 20; index++) { + hide(); + await service.show(undefined); + await service.openComposer(undefined); + } + + assert.deepStrictEqual({ afterFirst, afterCycles: state() }, { + afterFirst: { tabs: 1, liveSlots: 1, sessionListeners: afterFirst.sessionListeners, visible: true }, + afterCycles: { tabs: 1, liveSlots: 1, sessionListeners: afterFirst.sessionListeners, visible: true }, + }); + }); + + test('the active chat is the session a composer created from its first send on, before that first turn ends; a draft has none', async () => { + const status = observableValue('status', SessionStatus.Untitled); + const mainChat = upcastPartial({ resource: chatA }); + const created = upcastPartial({ status, mainChat: constObservable(mainChat) }); + const newSession = observableValue('newSession', undefined); + const { service } = createService({ newSession }); + const active = () => { const chat = service.activeChat.get(); return chat ? { session: chat.session === created, chat: chat.chat === mainChat } : 'none'; }; + + await service.openComposer(undefined); + const draft = active(); + newSession.set(created, undefined); + const untitled = active(); + status.set(SessionStatus.InProgress, undefined); + + assert.deepStrictEqual({ draft, untitled, firstTurn: active() }, { draft: 'none', untitled: 'none', firstTurn: { session: true, chat: true } }); + }); + + test('closing the tab of a composer releases it: opening and closing composers keeps nothing', async () => { + const { service, part, state } = createService(); + const baseline = state().sessionListeners; + + for (let index = 0; index < 20; index++) { + await service.openComposer(undefined); + await part.activeGroup.closeEditor(part.activeGroup.activeEditor!); + } + + assert.deepStrictEqual(state(), { tabs: 0, liveSlots: 0, sessionListeners: baseline, visible: false }); + }); + + function replacedPeerChat() { + const spec = { id: 'parent', title: 'Parent', chats: [{ id: 'peer', title: 'Peer' }] }; + const original = buildTestSession(spec); + const peer = original.chats.get('peer')!.chat.resource; + // The session of the provider registered again lists its peer chats only as it loads + const replacing = buildTestSession(spec); + const replacementChats = observableValue('chats', [replacing.mainChat.chat]); + const replacementLoading = observableValue('loading', true); + const replacement: ISession = { ...replacing.session, chats: replacementChats, loading: replacementLoading }; + return { original, peer, replacing, replacementChats, replacementLoading, replacement }; + } + + test('a peer chat whose provider is registered again follows the session that replaces its session, once that lists the chat', async () => { + const { original, peer, replacing, replacementChats, replacementLoading, replacement } = replacedPeerChat(); + let sessions: readonly ISession[] = [original.session]; + const { service, fireSessionsChanged } = createService({ sessions: () => sessions }); + await service.openChat(original.session, peer); + const shown = () => { + const active = service.activeChat.get(); + return { session: active?.session === original.session ? 'original' : active?.session === replacement ? 'replacement' : 'none', chat: active?.chat === original.chats.get('peer')!.chat ? 'original' : active?.chat === replacing.chats.get('peer')!.chat ? 'replacement' : 'none' }; + }; + + // Its provider goes, which hides its sessions without removing them + sessions = []; + fireSessionsChanged([]); + const whileGone = shown(); + // Its provider returns, without the peer chat yet + sessions = [replacement]; + fireSessionsChanged([replacement]); + const beforePeer = { ...shown(), showing: service.isShowing(peer) }; + transaction(tx => { + replacementChats.set([replacing.mainChat.chat, replacing.chats.get('peer')!.chat], tx); + replacementLoading.set(false, tx); + }); + + assert.deepStrictEqual({ whileGone, beforePeer, afterPeer: shown() }, { + whileGone: { session: 'original', chat: 'original' }, + beforePeer: { session: 'original', chat: 'original', showing: true }, + afterPeer: { session: 'replacement', chat: 'replacement' }, + }); + }); + + test('a chat of a session that the listing scope hides, such as of another folder, follows the session that replaces it', async () => { + const { original, peer, replacing, replacementChats, replacementLoading, replacement } = replacedPeerChat(); + let sessions: readonly ISession[] = [original.session]; + const { service, fireHiddenSessionsChanged } = createService({ sessions: () => sessions }); + await service.openChat(original.session, peer); + const shown = () => service.activeChat.get()?.session === replacement ? 'replacement' : service.activeChat.get()?.session === original.session ? 'original' : 'none'; + + // Its provider goes and returns, without its sessions being listed + sessions = []; + fireHiddenSessionsChanged([]); + sessions = [replacement]; + fireHiddenSessionsChanged([replacement]); + transaction(tx => { + replacementChats.set([replacing.mainChat.chat, replacing.chats.get('peer')!.chat], tx); + replacementLoading.set(false, tx); + }); + + assert.strictEqual(shown(), 'replacement'); + }); + + test('a peer chat whose provider is registered again waits for the session that replaces its session to list it, even once that says it loaded', async () => { + const { original, peer, replacing, replacementChats, replacementLoading, replacement } = replacedPeerChat(); + // A session created again says it loaded before its chats are known + replacementLoading.set(false, undefined); + let sessions: readonly ISession[] = [original.session]; + const { service, fireSessionsChanged } = createService({ sessions: () => sessions }); + await service.openChat(original.session, peer); + + sessions = []; + fireSessionsChanged([]); + sessions = [replacement]; + fireSessionsChanged([replacement]); + await timeout(0); + const beforePeer = service.isShowing(peer); + replacementChats.set([replacing.mainChat.chat, replacing.chats.get('peer')!.chat], undefined); + + assert.deepStrictEqual({ beforePeer, afterPeer: service.activeChat.get()?.session === replacement }, { beforePeer: true, afterPeer: true }); + }); + + test('a peer chat whose provider is registered again closes as the session that replaces its session is archived', async () => { + const { original, peer, replacement } = replacedPeerChat(); + const archived = observableValue('archived', false); + const archivable: ISession = { ...replacement, isArchived: archived }; + let sessions: readonly ISession[] = [original.session]; + const { service, fireSessionsChanged } = createService({ sessions: () => sessions }); + await service.openChat(original.session, peer); + + sessions = []; + fireSessionsChanged([]); + sessions = [archivable]; + fireSessionsChanged([archivable]); + const beforeArchived = service.isShowing(peer); + archived.set(true, undefined); + await timeout(0); + + assert.deepStrictEqual({ beforeArchived, archived: service.isShowing(peer) }, { beforeArchived: true, archived: false }); + }); + + test('a chat closes once its session is removed, such as deleted, but not as its provider goes away', async () => { + const built = buildTestSession({ id: 'parent', title: 'Parent', chats: [{ id: 'peer', title: 'Peer' }] }); + const peer = built.chats.get('peer')!.chat.resource; + let sessions: readonly ISession[] = [built.session]; + const { service, fireSessionsChanged } = createService({ sessions: () => sessions }); + await service.openChat(built.session, peer); + + // Its provider goes, which hides its sessions without removing them + sessions = []; + fireSessionsChanged([]); + await timeout(0); + const providerGone = service.isShowing(peer); + fireSessionsChanged([], [built.session]); + await timeout(0); + + assert.deepStrictEqual({ providerGone, removed: service.isShowing(peer) }, { providerGone: true, removed: false }); + }); + + test('restoring the chat part shown restores its chats, unless chat is hidden or disabled as the window starts, which hides it', async () => { + const restore = async (available: boolean) => { + const calls: string[] = []; + let visible = true; + await restoreAgentsChatPart( + upcastPartial({ isVisible: () => visible, setPartHidden: hidden => { visible = !hidden; calls.push(`hidden=${hidden}`); } }), + upcastPartial({ contextMatchesRules: () => available }), + () => upcastPartial({ restore: async () => { calls.push('restore chats'); } }), + ); + return { calls, visible }; + }; + + assert.deepStrictEqual({ available: await restore(true), aiOff: await restore(false) }, { + available: { calls: ['restore chats'], visible: true }, + aiOff: { calls: ['hidden=true'], visible: false }, + }); + }); + + test('opening a chat shows the chat part with the chat in a tab of its own; opening it again shows its tab', async () => { + const { service, layout } = createService(); + + await service.openChat(session, chatA); + await service.openChat(session, chatB); + await service.openChat(session, chatA); + + assert.deepStrictEqual({ layout: layout(), showing: [service.isShowing(chatA), service.isShowing(chatC)] }, { + layout: { groups: [{ tabs: ['/a', '/b'], active: '/a' }], activeGroup: 0, visible: true }, + showing: [true, false], + }); + }); + + test('a chat opened to the side opens in a group beside the active one, and a chat it shows already stays in its group', async () => { + const { service, layout } = createService(); + + await service.openChat(session, chatA, { sideBySide: true }); + const first = layout(); + await service.openChat(session, chatB, { sideBySide: true }); + const beside = layout(); + await service.openChat(session, chatA, { sideBySide: true }); + + assert.deepStrictEqual({ first, beside, again: layout() }, { + first: { groups: [{ tabs: ['/a'], active: '/a' }], activeGroup: 0, visible: true }, + beside: { groups: [{ tabs: ['/a'], active: '/a' }, { tabs: ['/b'], active: '/b' }], activeGroup: 1, visible: true }, + again: { groups: [{ tabs: ['/a'], active: '/a' }, { tabs: ['/b'], active: '/b' }], activeGroup: 0, visible: true }, + }); + }); + + test('closing a chat while its provider is released, which removes its session as its tab disposes, disposes its tab once, not from within its own dispose', async () => { + const built = buildTestSession({ id: 'released', title: 'Released' }); + let sessions: readonly ISession[] = [built.session]; + const { service, part, fireSessionsChanged } = createService({ sessions: () => sessions }); + await service.openChat(built.session, built.mainChat.chat.resource); + const input = part.activeGroup.activeEditor!; + // The providers are released as the chat part's editors are disposed: its session is removed meanwhile + Event.once(input.onWillDispose)(() => { + sessions = []; + fireSessionsChanged([], [built.session]); + }); + let depth = 0; + let reentered = 0; + const dispose = input.dispose.bind(input); + input.dispose = () => { + reentered += depth; + depth++; + try { + dispose(); + } finally { + depth--; + } + }; + + await service.closeChat(built.mainChat.chat.resource); + await timeout(0); + + assert.deepStrictEqual({ reentered, showing: service.isShowing(built.mainChat.chat.resource), disposed: input.isDisposed() }, { reentered: 0, showing: false, disposed: true }); + }); + + test('closing the last chat closes the chat part, which closing a chat it does not show leaves as it is', async () => { + const { service, layout } = createService(); + await service.openChat(session, chatA); + await service.openChat(session, chatB); + + await service.closeChat(chatC); + await service.closeChat(chatA); + const oneLeft = layout(); + await service.closeChat(chatB); + + assert.deepStrictEqual({ oneLeft, none: layout() }, { + oneLeft: { groups: [{ tabs: ['/b'], active: '/b' }], activeGroup: 0, visible: true }, + none: { groups: [{ tabs: [], active: null }], activeGroup: 0, visible: false }, + }); + }); + + test('the chat part stores its chats and the active one as the window saves, and shows them again in that order once restored', async () => { + const first = createService(); + await first.service.openChat(session, chatA); + await first.service.openChat(session, chatB); + await first.service.openChat(session, chatC); + await first.service.openChat(session, chatB); + const stored = first.save(); + + const second = createService({ stored, visible: true }); + await second.service.restore(); + + assert.deepStrictEqual({ stored: JSON.parse(stored!), restored: second.layout() }, { + stored: { chats: ['test:/a', 'test:/b', 'test:/c'], active: 1 }, + restored: { groups: [{ tabs: ['/a', '/b', '/c'], active: '/b' }], activeGroup: 0, visible: true }, + }); + }); + + test('the chat of the active tab shows as restored while the chat part restored it, and as opened once the user opens a chat', async () => { + const shownSession = buildTestSession({ id: 'shown', title: 'Shown', chats: [{ id: 'peer', title: 'Peer' }] }); + const main = shownSession.mainChat.chat.resource; + const peer = shownSession.chats.get('peer')!.chat.resource; + const { service } = createService({ stored: JSON.stringify({ chats: [main.toString(), peer.toString()], active: 1 }), visible: true, sessions: () => [shownSession.session] }); + const shown = async () => { + await timeout(0); + const value = service.shownSession.get(); + return value && `${isEqual(value.resource, main) ? 'main' : isEqual(value.resource, peer) ? 'peer' : value.resource.toString()} ${value.reason}`; + }; + + await service.restore(); + const restored = await shown(); + await service.openChat(shownSession.session, main); + + assert.deepStrictEqual({ restored, opened: await shown() }, { restored: 'peer restore', opened: 'main open' }); + }); + + test('its composers offer the window\'s folders, or those a feature provides while it does', () => { + const { service } = createService(); + const folders = () => service.getComposerFolderOptions().allowedFolders?.get().map(folder => folder.toString()); + + const windowFolders = folders(); + const provided = service.setComposerFolderOptions(() => ({ allowedFolders: constObservable([URI.file('/worktree')]) })); + const whileProvided = folders(); + provided.dispose(); + + assert.deepStrictEqual({ whileProvided, afterwards: folders() }, { whileProvided: ['file:///worktree'], afterwards: windowFolders }); + }); + + test('with no chats, the chat part removes what it stored, and restoring nothing closes it', async () => { + const first = createService({ stored: JSON.stringify({ chats: ['test:/a'], active: 0 }) }); + await first.service.openChat(session, chatA); + await first.service.closeChat(chatA); + const stored = first.save(); + + const unreadable = createService({ stored: 'not json', visible: true }); + await unreadable.service.restore(); + + assert.deepStrictEqual({ stored, restored: unreadable.layout().visible }, { stored: undefined, restored: false }); + }); + + test('restoring after a chat opened since the window started keeps the chats of now, not those of before the reload', async () => { + const { service, layout } = createService({ stored: JSON.stringify({ chats: ['test:/a', 'test:/b'], active: 0 }) }); + + await service.openChat(session, chatC); + await service.restore(); + + assert.deepStrictEqual(layout(), { groups: [{ tabs: ['/c'], active: '/c' }], activeGroup: 0, visible: true }); + }); + + test('showing the chat part shows the chats it had as it closed, and a new-session composer when it had none', async () => { + const withChats = createService(); + await withChats.service.openChat(session, chatA); + withChats.hide(); + await withChats.service.show(undefined); + + const empty = createService(); + await empty.service.show(undefined); + + assert.deepStrictEqual({ withChats: withChats.layout(), empty: empty.layout() }, { + withChats: { groups: [{ tabs: ['/a'], active: '/a' }], activeGroup: 0, visible: true }, + empty: { groups: [{ tabs: ['composer'], active: 'composer' }], activeGroup: 0, visible: true }, + }); + }); + + test('chat turning off, such as as AI features are disabled, closes the chat part', async () => { + const { service, layout, setChatEnabled } = createService({ chatEnabled: true }); + await service.openChat(session, chatA); + + setChatEnabled(false); + + assert.deepStrictEqual(layout(), { groups: [{ tabs: ['/a'], active: '/a' }], activeGroup: 0, visible: false }); + }); + + test('its tabs show as a chat window\'s do, following workbench.editor.showTabs as it changes', async () => { + const { service, part, setShowTabs } = createService(); + await service.openChat(session, chatA); + const shown = part.options; + + setShowTabs('none'); + + assert.deepStrictEqual({ shown, none: part.options }, { + shown: { doubleClickTabToToggleEditorGroupSizes: 'off', tabSizing: 'fixed', tabSizingFixedMinWidth: 80, tabSizingFixedMaxWidth: 160 }, + none: { doubleClickTabToToggleEditorGroupSizes: 'off', showTabs: 'single', tabSizing: 'fixed', tabSizingFixedMinWidth: 80, tabSizingFixedMaxWidth: 160 }, + }); + }); +}); diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPartActions.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPartActions.test.ts new file mode 100644 index 00000000000000..08699919521ca6 --- /dev/null +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPartActions.test.ts @@ -0,0 +1,120 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { constObservable } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { isIMenuItem, MenuId, MenuRegistry } from '../../../../../../platform/actions/common/actions.js'; +import { CommandsRegistry } from '../../../../../../platform/commands/common/commands.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; +import { EditorInput } from '../../../../../common/editor/editorInput.js'; +import { IEditorService } from '../../../../../services/editor/common/editorService.js'; +import { ISession, ISessionWorkspace } from '../../../../../services/sessions/common/session.js'; +import { getEditorWindowComposerTarget } from '../../../browser/editorWindow/editorWindowSessionsPresenter.js'; +import { IAgentsChatPartOpenOptions, IAgentsChatPartService } from '../../../browser/agentsView/agentsChatPart.js'; +import { registerAgentsChatPartActions, registerAgentsViewNewSessionActions } from '../../../browser/agentsView/agentsChatPartActions.js'; +import { KeybindingsRegistry } from '../../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { Menus } from '../../../browser/menus.js'; +import { INewSessionTarget } from '../../../browser/parts/chatView.js'; +import { ISessionSection, SessionItemContextMenuId, SessionSectionToolbarMenuId } from '../../../browser/views/sessionsList.js'; +import { buildTestSession } from '../../common/testSessionBuilder.js'; + +suite('Agents chat part actions', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + /** A session working in `workingDirectory`, such as a worktree of the repository `root`. */ + function createSession(id: string, root: string, workingDirectory: string, createdAt: number): ISession { + const workspace: ISessionWorkspace = { + uri: URI.file(root), label: id, icon: undefined!, requiresWorkspaceTrust: false, isVirtualWorkspace: false, + folders: [{ root: URI.file(root), workingDirectory: URI.file(workingDirectory), name: id, description: undefined, gitRepository: undefined }], + }; + return { ...buildTestSession({ id, title: id }).session, providerId: 'local-agent-host', sessionType: 'copilotcli', createdAt: new Date(createdAt), workspace: constObservable(workspace) }; + } + + function run(commandId: string, arg: unknown) { + const opened: { readonly target: INewSessionTarget | undefined; readonly newComposer: boolean | undefined }[] = []; + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IAgentsChatPartService, upcastPartial({ + openComposer: async (target: INewSessionTarget | undefined, options?: IAgentsChatPartOpenOptions) => { opened.push({ target, newComposer: options?.newComposer }); }, + })); + return instantiationService.invokeFunction(async accessor => { + await CommandsRegistry.getCommand(commandId)!.handler(accessor, arg); + return opened.map(({ target, newComposer }) => ({ folder: target?.folderUri?.path, providerId: target?.providerId, sessionTypeId: target?.sessionTypeId, noWorkspace: !!target && !target.folderUri, newComposer })); + }); + } + + const menuWhen = (menu: Parameters[0], commandId: string) => MenuRegistry.getMenuItems(menu).filter(isIMenuItem).find(item => item.command.id === commandId)?.when?.serialize(); + + test('starts a new session in the worktree of a Worktree section, as its latest session, but not in its Other section; and in the folder of a session', async () => { + store.add(registerAgentsViewNewSessionActions()); + const older = createSession('older', '/work/repo', '/work/repo.worktrees/feature', 1); + const latest = { ...createSession('latest', '/work/repo', '/work/repo.worktrees/feature', 2), sessionType: 'claude' }; + const section = (id: string) => upcastPartial({ id, label: id, sessions: [older, latest] }); + + assert.deepStrictEqual({ + worktree: await run('workbench.action.agentsView.newSessionInWorktree', section(`worktree:${URI.file('/work/repo.worktrees/feature').toString()}`)), + other: await run('workbench.action.agentsView.newSessionInWorktree', section('worktree:other')), + sameFolder: await run('workbench.action.agentsView.newSessionInSameFolder', older), + menus: { + sectionToolbar: menuWhen(SessionSectionToolbarMenuId, 'workbench.action.agentsView.newSessionInWorktree'), + sectionContext: menuWhen(Menus.SessionSectionContext, 'workbench.action.agentsView.newSessionInWorktree'), + session: menuWhen(SessionItemContextMenuId, 'workbench.action.agentsView.newSessionInSameFolder'), + }, + }, { + worktree: [{ folder: '/work/repo.worktrees/feature', providerId: 'local-agent-host', sessionTypeId: 'claude', noWorkspace: false, newComposer: true }], + other: [], + sameFolder: [{ folder: '/work/repo.worktrees/feature', providerId: 'local-agent-host', sessionTypeId: 'copilotcli', noWorkspace: false, newComposer: true }], + menus: { + sectionToolbar: 'chatIsEnabled && sessionSection.type == \'worktree\'', + sectionContext: 'chatIsEnabled && sessionSection.type == \'worktree\'', + session: 'chatIsEnabled && !sessionItem.isMultiSelection', + }, + }); + }); + + test('a new quick chat opens a composer of its own without a workspace, from the Chats section and the Command Palette, without a keybinding', async () => { + store.add(registerAgentsChatPartActions()); + const id = 'workbench.action.agentsView.newQuickChat'; + + assert.deepStrictEqual({ + opened: await run(id, undefined), + menus: { + sectionToolbar: menuWhen(SessionSectionToolbarMenuId, id), + sectionContext: menuWhen(Menus.SessionSectionContext, id), + commandPalette: menuWhen(MenuId.CommandPalette, id), + }, + keybindings: KeybindingsRegistry.getDefaultKeybindings().filter(keybinding => keybinding.command === id).length, + }, { + opened: [{ folder: undefined, providerId: undefined, sessionTypeId: undefined, noWorkspace: true, newComposer: true }], + menus: { + sectionToolbar: 'agentHostEnabled && chatIsEnabled && sessionSection.type == \'quickchats\'', + sectionContext: 'agentHostEnabled && chatIsEnabled && sessionSection.type == \'quickchats\'', + commandPalette: 'agentHostEnabled && chatIsEnabled', + }, + keybindings: 0, + }); + }); + + test('a new session of the chat part aims at the window\'s folder of the active editor, else at its first', () => { + const folders = [URI.file('/work/vscode'), URI.file('/work/tools')].map(uri => upcastPartial({ uri })); + const workspaceContextService = upcastPartial({ + getWorkspace: () => upcastPartial({ folders }), + getWorkspaceFolder: (resource: URI) => folders.find(folder => resource.path.startsWith(`${folder.uri.path}/`)) ?? null, + }); + const target = (activeResource: URI | undefined) => getEditorWindowComposerTarget(workspaceContextService, upcastPartial({ + activeEditor: activeResource && upcastPartial({ resource: activeResource }), + }))?.folderUri?.path; + + assert.deepStrictEqual({ + inSecond: target(URI.file('/work/tools/package.json')), + outside: target(URI.file('/elsewhere/file.ts')), + none: target(undefined), + }, { inSecond: '/work/tools', outside: '/work/vscode', none: '/work/vscode' }); + }); +}); diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts new file mode 100644 index 00000000000000..d942dac9c775d8 --- /dev/null +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts @@ -0,0 +1,257 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; +import { isCancellationError } from '../../../../../../base/common/errors.js'; +import { toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { constObservable, IObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { IEditorService } from '../../../../../services/editor/common/editorService.js'; +import { IWorkbenchLayoutService } from '../../../../../services/layout/browser/layoutService.js'; +import { IChat, ISession } from '../../../../../services/sessions/common/session.js'; +import { IChatWidget, IChatWidgetService } from '../../../../chat/browser/chat.js'; +import { IChatModelReference, IChatService } from '../../../../chat/common/chatService/chatService.js'; +import { IAgentsChatPartService } from '../../../browser/agentsView/agentsChatPart.js'; +import { AgentsChatViewHost } from '../../../browser/agentsView/agentsChatViewHost.js'; +import { IAgentsChatPartFrame } from '../../../browser/agentsView/agentsChatPartFrame.js'; +import { IChatViewHostShownSession } from '../../../../chat/browser/chatViewHost.js'; +import { LocalChatSessionUri } from '../../../../chat/common/model/chatUri.js'; +import { IAgentsViewProvidersService } from '../../../browser/agentsView/agentsViewProviders.js'; +import { ISessionsManagementService } from '../../../common/sessionsManagement.js'; + +suite('AgentsChatViewHost', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + interface IHostOptions { + readonly chatAvailable?: boolean; + readonly chatPart?: Partial; + readonly sessionsManagementService?: Partial; + readonly widgets?: IChatWidget[]; + readonly providersAcquired?: () => void; + readonly contentCreated?: IObservable; + } + + function createHost(options: IHostOptions = {}) { + const calls: string[] = []; + const composerWidget = upcastPartial({}); + const chatPart = upcastPartial({ + activeChat: constObservable(undefined), + show: async (_target, showOptions) => { calls.push(`show preserveFocus=${showOptions?.preserveFocus}`); }, + openComposer: async (target, openOptions) => { calls.push(`openComposer sessionType=${target?.sessionTypeId} newComposer=${openOptions?.newComposer}`); }, + getComposerWidget: async () => composerWidget, + openChat: async session => { calls.push(`openChat ${session.resource.toString()}`); }, + isShowing: () => false, + ...options.chatPart, + }); + const host = new AgentsChatViewHost( + chatPart, + upcastPartial({ getSessionForChatResource: () => undefined, getSession: () => undefined, getSessions: () => [], onDidChangeSession: () => toDisposable(() => { }), ...options.sessionsManagementService }), + upcastPartial({ getAllWidgets: () => options.widgets ?? [] }), + upcastPartial({ isVisible: () => false }), + upcastPartial({ getWorkspace: () => upcastPartial>({ folders: [] }) }), + upcastPartial({ contextMatchesRules: () => options.chatAvailable ?? true }), + upcastPartial({ + acquire: () => { + options.providersAcquired?.(); + calls.push('acquire'); + return toDisposable(() => calls.push('release')); + }, + }), + upcastPartial({ activeEditor: undefined }), + upcastPartial({ + acquireOrLoadSession: async resource => { + calls.push(`hold ${resource.toString()}`); + return upcastPartial({ dispose: () => calls.push(`release ${resource.toString()}`) }); + }, + }), + upcastPartial({ info: message => { calls.push(`info ${message}`); return undefined!; } }), + upcastPartial({ contentCreated: options.contentCreated ?? constObservable(true) }), + ); + return { host, calls, composerWidget }; + } + + /** A session of `resource` whose chat the chat part shows with a widget. */ + function sessionOf(resource: URI) { + const session = upcastPartial({ resource }); + const owner = { session, chat: upcastPartial({ resource }) }; + const widget = upcastPartial({ viewModel: upcastPartial({ sessionResource: resource }) }); + return { owner, widget }; + } + + test('opening a session by its resource holds the providers before it looks the session up, as they may be released, until the chat part shows it', async () => { + const chatResource = URI.parse('agent-host-copilot:/session-1'); + const { owner, widget } = sessionOf(chatResource); + let registered = false; + const lookups: boolean[] = []; + const { host, calls } = createHost({ + // Its session is known only once the providers are registered + sessionsManagementService: { getSessionForChatResource: () => { lookups.push(registered); return registered ? owner : undefined; } }, + widgets: [widget], + providersAcquired: () => registered = true, + }); + + const opened = await host.openSession(chatResource); + + assert.deepStrictEqual({ opened: opened === widget, lookups, calls }, { opened: true, lookups: [true], calls: ['acquire', 'openChat agent-host-copilot:/session-1', 'release'] }); + }); + + test('what would show the Chat view gets the widget of the composer it shows, whose submits create the session', async () => { + const { host, calls, composerWidget } = createHost(); + + const revealed = await host.reveal(true); + const toggled = await host.toggle(); + + assert.deepStrictEqual({ revealed: revealed === composerWidget, toggled, calls }, { + revealed: true, + toggled: true, + calls: ['show preserveFocus=true', 'show preserveFocus=undefined'], + }); + }); + + test('reveals only a session that the chat part shows', async () => { + const chatResource = URI.parse('agent-host-copilot:/session-1'); + const { owner, widget } = sessionOf(chatResource); + const showing = createHost({ chatPart: { isShowing: () => true }, sessionsManagementService: { getSessionForChatResource: () => owner }, widgets: [widget] }); + const notShowing = createHost({ sessionsManagementService: { getSessionForChatResource: () => owner }, widgets: [widget] }); + + assert.deepStrictEqual({ + showing: await showing.host.revealIfShowing(chatResource) === widget, + notShowing: await notShowing.host.revealIfShowing(chatResource), + notShowingCalls: notShowing.calls, + }, { showing: true, notShowing: undefined, notShowingCalls: [] }); + }); + + test('shows nothing while chat is hidden or disabled, such as with AI features off', async () => { + const { host, calls } = createHost({ chatAvailable: false }); + + const results = [await host.reveal(), await host.openSession(URI.parse('test:/chat')), await host.toggle()]; + + assert.deepStrictEqual({ results, calls }, { results: [undefined, undefined, false], calls: [] }); + }); + + test('a started session that runs shows in the chat part, its model held until the start ends', async () => { + const untitled = URI.parse('agent-host-copilot:/untitled-1'); + const real = URI.parse('agent-host-copilot:/session-1'); + const { owner, widget } = sessionOf(real); + const { host, calls } = createHost({ sessionsManagementService: { getSessionForChatResource: () => owner }, widgets: [widget] }); + + const start = await host.startSession(untitled, { local: false, importingConversation: false }); + await start.moved(real); + const shown = await start.complete(real, true); + start.dispose(); + + assert.deepStrictEqual({ shown, calls }, { + shown: true, + calls: ['hold agent-host-copilot:/untitled-1', 'acquire', 'openChat agent-host-copilot:/session-1', 'release', 'release agent-host-copilot:/untitled-1'], + }); + }); + + test('a new session that did not run is not shown: a composer of its type shows instead, which gets the request back', async () => { + const untitled = URI.parse('agent-host-copilot:/untitled-1'); + const { host, calls, composerWidget } = createHost(); + + const start = await host.startSession(untitled, { local: false, importingConversation: false }); + const shown = await start.complete(untitled, false); + const composer = await start.createComposer(); + start.dispose(); + + assert.deepStrictEqual({ shown, composer: composer === composerWidget, calls }, { + shown: false, + composer: true, + calls: ['hold agent-host-copilot:/untitled-1', 'openComposer sessionType=agent-host-copilot newComposer=true', 'release agent-host-copilot:/untitled-1'], + }); + }); + + test('completing a session that did not run waits for its composer to show, and fails as showing it fails', async () => { + const untitled = URI.parse('agent-host-copilot:/untitled-1'); + const opening = new DeferredPromise(); + const { host } = createHost({ chatPart: { openComposer: () => opening.p } }); + + const start = await host.startSession(untitled, { local: false, importingConversation: false }); + let settled = false; + const completed = start.complete(untitled, false).finally(() => settled = true); + await timeout(0); + const pendingWhileShowing = !settled; + opening.error(new Error('no composer')); + const error = await completed.then(() => undefined, (e: Error) => e.message); + start.dispose(); + + assert.deepStrictEqual({ pendingWhileShowing, error }, { pendingWhileShowing: true, error: 'no composer' }); + }); + + test('does not start local chat, which the chat part does not show', async () => { + const { host, calls } = createHost(); + + const error = await host.startSession(URI.parse('vscode-chat-session://local/1'), { local: true, importingConversation: false }).then(() => undefined, e => e); + + assert.deepStrictEqual({ cancelled: isCancellationError(error), calls }, { + cancelled: true, + calls: ['info Local chat sessions are not available while chats open in the chat part.'], + }); + }); + + test('deactivated while it looks a session up, it does not open the chat part', async () => { + const chatResource = URI.parse('agent-host-copilot:/session-1'); + const { owner, widget } = sessionOf(chatResource); + const lookup = new DeferredPromise(); + const { host, calls } = createHost({ + sessionsManagementService: { + getSessionForChatResource: () => lookup.isSettled ? owner : undefined, + onDidChangeSession: listener => { void lookup.p.then(() => listener([])); return toDisposable(() => { }); }, + }, + widgets: [widget], + }); + + const opening = host.openSession(chatResource); + host.deactivate(); + lookup.complete(); + + assert.deepStrictEqual({ opened: await opening, calls }, { opened: undefined, calls: ['acquire', 'release'] }); + }); + + test('shows the chats of agent host sessions and of the window\'s sessions, and leaves other chats, such as local chat, to the Chat view', () => { + const known = URI.parse('copilot-cloud-agent:/known'); + const { host } = createHost({ sessionsManagementService: { getSessionForChatResource: resource => resource.toString() === known.toString() ? sessionOf(known).owner : undefined } }); + + assert.deepStrictEqual({ + agentHost: host.canShow(URI.parse('agent-host-copilot:/session-1')), + known: host.canShow(known), + local: host.canShow(LocalChatSessionUri.forSession('local-1')), + extension: host.canShow(URI.parse('some-extension-chat:/session-1')), + }, { agentHost: true, known: true, local: false, extension: false }); + }); + + test('New Chat opens a new-session composer in the chat part, unless chat is hidden or disabled', () => { + const available = createHost(); + const unavailable = createHost({ chatAvailable: false }); + + assert.deepStrictEqual({ + available: available.host.newChat(), availableCalls: available.calls, + unavailable: unavailable.host.newChat(), unavailableCalls: unavailable.calls, + }, { + available: true, availableCalls: ['openComposer sessionType=undefined newComposer=true'], + unavailable: false, unavailableCalls: [], + }); + }); + + test('reports the chat of the chat part\'s active tab only once the chat part exists, without creating it', () => { + const contentCreated = observableValue('contentCreated', false); + const shown: IChatViewHostShownSession = { resource: URI.parse('agent-host-copilot:/session-1'), reason: 'restore' }; + const read: string[] = []; + const { host } = createHost({ contentCreated, chatPart: { get shownSession() { read.push('shownSession'); return constObservable(shown); } } }); + + const before = host.shownSession.get(); + contentCreated.set(true, undefined); + + assert.deepStrictEqual({ before, after: host.shownSession.get(), read }, { before: undefined, after: shown, read: ['shownSession'] }); + }); +}); diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/editorWindowChatPartService.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/editorWindowChatPartService.test.ts new file mode 100644 index 00000000000000..5fc9408eb37fc0 --- /dev/null +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/editorWindowChatPartService.test.ts @@ -0,0 +1,126 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../../../base/common/event.js'; +import { toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { constObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { ContextKeyValue, IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchLayoutService, Parts } from '../../../../../services/layout/browser/layoutService.js'; +import { ILifecycleService } from '../../../../../services/lifecycle/common/lifecycle.js'; +import { IChatViewHostService } from '../../../../chat/browser/chatViewHost.js'; +import { IAgentsChatPartService } from '../../../browser/agentsView/agentsChatPart.js'; +import { EditorWindowChatPartService } from '../../../browser/agentsView/agentsChatPartContribution.js'; +import { IAgentsChatPartFrame } from '../../../browser/agentsView/agentsChatPartFrame.js'; +import { AgentsChatViewHost } from '../../../browser/agentsView/agentsChatViewHost.js'; +import { IEditorWindowSessionsPresenterService } from '../../../browser/editorWindow/editorWindowSessionsPresenter.js'; +import { IEditorWindowSessionsRuntime } from '../../../browser/editorWindow/editorWindowSessionsRuntime.js'; + +suite('EditorWindowChatPartService', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + /** The service, as the window starts with the chat part shown or not, recording what it does. */ + function createService(state: { visible: boolean }) { + const events: string[] = []; + const layoutService = upcastPartial({ + setChatPartAnchor: anchor => events.push(`anchor ${anchor?.viewId}`), + onDidChangePartVisibility: Event.None, + isVisible: part => part === Parts.CHAT_PART && state.visible, + setPartHidden: (hidden, part) => { + if (part === Parts.CHAT_PART) { + state.visible = !hidden; + events.push(hidden ? 'hide' : 'show'); + } + }, + }); + const contextKeyService = upcastPartial({ + createKey: () => upcastPartial>({ set: () => { }, reset: () => { } }), + contextMatchesRules: () => true, + }); + const chatPart = upcastPartial({ restore: async () => { events.push('restore'); } }); + const instantiationService = upcastPartial({ + createInstance: (ctor: unknown) => ctor === AgentsChatViewHost + ? upcastPartial({ deactivate: () => events.push('deactivate host') }) + : toDisposable(() => { }), + invokeFunction: (fn: (accessor: ServicesAccessor, ...args: TS) => R, ...args: TS): R => fn(upcastPartial({ get: (): T => chatPart as T }), ...args), + }); + const service = disposables.add(new EditorWindowChatPartService( + instantiationService, + layoutService, + contextKeyService, + upcastPartial({ when: () => Promise.resolve() }), + upcastPartial({ registerHost: () => { events.push('register host'); return toDisposable(() => events.push('unregister host')); } }), + upcastPartial({ registerPresenter: () => { events.push('register presenter'); return toDisposable(() => events.push('unregister presenter')); } }), + upcastPartial({ enable: () => { events.push('hold providers'); return toDisposable(() => events.push('release providers')); } }), + upcastPartial({ get part() { events.push('create part'); return undefined!; }, contentCreated: constObservable(false) }), + )); + return { service, events }; + } + + test('does nothing until a feature enables it; features share the chat part, which docks beside the anchor of the highest priority', async () => { + const { service, events } = createService({ visible: false }); + const step = (label: string) => events.push(`-- ${label}`); + + step('nothing enabled'); + const list = service.enable({ anchorViewId: 'list', anchorPriority: 1, enabled: constObservable(true) }); + step('the list enables it'); + const view = service.enable({ anchorViewId: 'view', anchorPriority: 2, enabled: constObservable(true) }); + step('the view enables it'); + view.dispose(); + step('the view no longer does'); + list.dispose(); + await timeout(0); + + assert.deepStrictEqual(events, [ + '-- nothing enabled', + 'create part', 'hold providers', 'register host', 'register presenter', 'anchor list', + '-- the list enables it', + 'anchor view', + '-- the view enables it', + 'anchor list', + '-- the view no longer does', + 'unregister host', 'deactivate host', 'unregister presenter', 'anchor undefined', 'release providers', + ]); + }); + + test('while chats do not open in it, it neither restores nor shows, and the Chat view keeps its chats; once they do, it replaces the Chat view', async () => { + const enabled = observableValue('enabled', false); + const { service, events } = createService({ visible: true }); + const step = (label: string) => events.push(`-- ${label}`); + + const registration = disposables.add(service.enable({ anchorViewId: 'chat', anchorPriority: 1, enabled })); + // The window gets ready and restores + await timeout(0); + step('chats open in the chat part'); + enabled.set(true, undefined); + step('they no longer do'); + enabled.set(false, undefined); + registration.dispose(); + + assert.deepStrictEqual(events, [ + 'create part', 'hold providers', 'anchor chat', + 'hide', + '-- chats open in the chat part', + 'register host', 'register presenter', + '-- they no longer do', + 'unregister host', 'deactivate host', 'unregister presenter', + 'anchor undefined', 'release providers', + ]); + }); + + test('restores the chats it showed as the window last closed, while chats open in it', async () => { + const { service, events } = createService({ visible: true }); + + disposables.add(service.enable({ anchorViewId: 'list', anchorPriority: 1, enabled: constObservable(true) })); + await timeout(0); + + assert.deepStrictEqual(events, ['create part', 'hold providers', 'register host', 'register presenter', 'anchor list', 'restore']); + }); +}); From ad3aede8cc9658780f206a2d179972a747230990 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:16:39 +0100 Subject: [PATCH 020/165] Chat part: report 'restore' only for the tabs that its restore shows As the Chat view does since #348304: a chat that the user opens while the chat part restores its tabs is opened, not restored. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentsView/agentsChatPart.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts index 3414c094618f63..acb2e9845365c4 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts @@ -198,6 +198,8 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart private readonly _activeInput = observableValue(this, undefined); /** Why the active tab became active: as {@link restore} showed it, or else as the user opened or picked it. */ private readonly _activeReason = observableValue(this, 'open'); + /** The tabs that {@link restore} shows, while it does: a chat the user opens meanwhile is opened, not restored. */ + private readonly _restoringTabs = new Set(); readonly shownSession: IObservable; private readonly _composerFolderProviders: (() => IAgentsChatPartComposerFolderOptions)[] = []; readonly activeChat: IObservable; @@ -432,10 +434,17 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart const part = this._ensurePart(); const active = chats[Math.min(Math.max(stored?.active ?? 0, 0), chats.length - 1)]; - // Only the active tab shows its chat: the others create their views once they show. A group activates the first editor - // it opens, inactive or not, so the active tab opens last. - await this._editorService.openEditors(chats.map(chat => ({ editor: chat.input, options: { pinned: true, preserveFocus: true, inactive: true } })), part.activeGroup); - await this._editorService.openEditor(active.input, { pinned: true, preserveFocus: true }, part.activeGroup); + for (const chat of chats) { + this._restoringTabs.add(chat.input); + } + try { + // Only the active tab shows its chat: the others create their views once they show. A group activates the first + // editor it opens, inactive or not, so the active tab opens last. + await this._editorService.openEditors(chats.map(chat => ({ editor: chat.input, options: { pinned: true, preserveFocus: true, inactive: true } })), part.activeGroup); + await this._editorService.openEditor(active.input, { pinned: true, preserveFocus: true }, part.activeGroup); + } finally { + this._restoringTabs.clear(); + } this.composers.didOpen(chats.map(chat => chat.chat.get())); if (this._layoutService.isVisible(Parts.CHAT_PART)) { this._log('restore'); @@ -538,7 +547,7 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart const active = part.activeGroup.activeEditor ?? undefined; if (active !== this._activeInput.get()) { transaction(tx => { - this._activeReason.set(this._restoring ? 'restore' : 'open', tx); + this._activeReason.set(active && this._restoringTabs.has(active) ? 'restore' : 'open', tx); this._activeInput.set(active, tx); }); } From bb450bc1b14f08208377bc06d3f83366ecbfeccc Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:17:45 +0100 Subject: [PATCH 021/165] Chat part: report a composer's first send as opened, also in a restored tab As the Chat view reports the chat its first request commits: the session that a composer's first send created shows as opened, also in a composer tab that the chat part restored. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessions/browser/agentsView/agentsChatPart.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts index acb2e9845365c4..c7960184dd09fe 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts @@ -200,6 +200,8 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart private readonly _activeReason = observableValue(this, 'open'); /** The tabs that {@link restore} shows, while it does: a chat the user opens meanwhile is opened, not restored. */ private readonly _restoringTabs = new Set(); + /** The tabs that are, or were, new-session composers: the session such a tab shows is the one its first send created. */ + private readonly _composerTabs = new WeakSet(); readonly shownSession: IObservable; private readonly _composerFolderProviders: (() => IAgentsChatPartComposerFolderOptions)[] = []; readonly activeChat: IObservable; @@ -237,8 +239,14 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart return input ? this._chats.get(input)?.shown.read(reader) : undefined; }); this.shownSession = derived(this, reader => { + const input = this._activeInput.read(reader); const active = this.activeChat.read(reader); - return active ? { resource: active.chat.resource, reason: this._activeReason.read(reader) } : undefined; + if (!input || !active) { + return undefined; + } + // A composer shows a session only from its first send on, which the user did, also in a tab the chat part restored + const reason = this._composerTabs.has(input) ? 'open' : this._activeReason.read(reader); + return { resource: active.chat.resource, reason }; }); // The window's host, as its service, shows the active tab's chat if (this.mainWindowHost instanceof EditorWindowMainSessionHost) { @@ -485,6 +493,9 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart private _addChat(resource: URI, composer: ChatWindowComposerModel | undefined): AgentsChatPartChat { const chat = this._instantiationService.createInstance(AgentsChatPartChat, resource, composer, constObservable(this._window), this._activeInput, (chat: AgentsChatPartChat) => this._closeChat(chat)); this._chats.set(chat.input, chat); + if (composer) { + this._composerTabs.add(chat.input); + } // It stays in the chat part: its editor shows there chat.input.setMoveRestriction({ reason: localize('agentsChatPart.cannotMove', "A chat stays in the chat part."), From 43ef657b3420287649be6c1ec6d973b85f25af61 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:18:34 +0100 Subject: [PATCH 022/165] Chat part: name its availability AgentsViewAvailable, for the sessions list to share The Sessions view and the chat part show sessions where the same holds: the sessions list of an editor window imports it from here instead of declaring its own. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/sessions/browser/agentsView/agentsChatPart.ts | 4 ++-- .../sessions/browser/agentsView/agentsChatPartContext.ts | 6 +++--- .../sessions/browser/agentsView/agentsChatViewHost.ts | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts index c7960184dd09fe..86ce946b2eb043 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts @@ -45,7 +45,7 @@ import { ISessionsListModelService } from '../sessionsListModelService.js'; import { ISessionsProvidersService } from '../sessionsProvidersService.js'; import { IAgentsChatPartFrame } from './agentsChatPartFrame.js'; import { AgentsChatPartAction, logAgentsChatPartAction } from './agentsChatPartTelemetry.js'; -import { AgentsChatPartAvailable, InAgentsChatPartContext } from './agentsChatPartContext.js'; +import { AgentsViewAvailable, InAgentsChatPartContext } from './agentsChatPartContext.js'; import { EditorWindowMainSessionHost } from './editorWindowMainSessionHost.js'; /** The chats of the chat part, which it shows again as the window restores. */ @@ -146,7 +146,7 @@ export async function restoreAgentsChatPart(layoutService: IWorkbenchLayoutServi if (!layoutService.isVisible(Parts.CHAT_PART)) { return; } - if (!contextKeyService.contextMatchesRules(AgentsChatPartAvailable)) { + if (!contextKeyService.contextMatchesRules(AgentsViewAvailable)) { layoutService.setPartHidden(true, Parts.CHAT_PART); return; } diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContext.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContext.ts index 1c468ee8c4aeb1..d5f232949af02c 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContext.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContext.ts @@ -10,10 +10,10 @@ import { ChatEntitlementContextKeys } from '../../../../services/chat/common/cha import { ChatConfiguration } from '../../../chat/common/constants.js'; /** - * Where the chat part can show chats of sessions in an editor window: where AI features and agents are enabled, as for the - * Agents window, in local windows. + * Where an editor window can show sessions, such as in the chat part or a sessions list: where AI features and agents are + * enabled, as for the Agents window, in local windows. Known as the window starts, so that what shows sessions restores. */ -export const AgentsChatPartAvailable: ContextKeyExpression = ContextKeyExpr.and( +export const AgentsViewAvailable: ContextKeyExpression = ContextKeyExpr.and( ChatEntitlementContextKeys.Setup.hidden.negate(), ChatEntitlementContextKeys.Setup.disabledInWorkspace.negate(), ContextKeyExpr.has(`config.${ChatConfiguration.AgentEnabled}`), diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts index e3f09b443968cc..9c904d8810e2a1 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts @@ -25,7 +25,7 @@ import { INewSessionTarget } from '../parts/chatView.js'; import { ISessionsManagementService } from '../../common/sessionsManagement.js'; import { IAgentsChatPartService } from './agentsChatPart.js'; import { getEditorWindowComposerTarget } from '../editorWindow/editorWindowSessionsPresenter.js'; -import { AgentsChatPartAvailable } from './agentsChatPartContext.js'; +import { AgentsViewAvailable } from './agentsChatPartContext.js'; import { IAgentsChatPartFrame } from './agentsChatPartFrame.js'; import { IAgentsViewProvidersService, waitForAgentsViewSession } from './agentsViewProviders.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; @@ -87,7 +87,7 @@ export class AgentsChatViewHost implements IChatViewHost { /** Whether it shows anything: not while chat is hidden or disabled, such as with AI features off. */ private get _available(): boolean { - return this._contextKeyService.contextMatchesRules(AgentsChatPartAvailable); + return this._contextKeyService.contextMatchesRules(AgentsViewAvailable); } async reveal(preserveFocus?: boolean): Promise { From f975be4e2e528ccf05e439b377e99e4888ed63eb Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:25:39 +0100 Subject: [PATCH 023/165] Chat part: show the chats of every type the window's sessions providers offer Aligns AgentsChatViewHost.canShow with #348302's fall-through as agreed: besides agent host chats and the chats the window's sessions resolve, it also shows a session resolved by its resource and a chat of a type a provider offers, such as an untitled new session's. Local chat and the chats of other kinds stay in the Chat view. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentsView/agentsChatViewHost.ts | 18 +++++++++++---- .../agentsView/agentsChatViewHost.test.ts | 23 +++++++++++++------ 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts index 9c904d8810e2a1..8e0ec842095f4e 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts @@ -18,9 +18,9 @@ import { IWorkbenchLayoutService, Parts } from '../../../../services/layout/brow import { IChatWidget, IChatWidgetService } from '../../../chat/browser/chat.js'; import { IChatViewHost, IChatViewHostOpenOptions, IChatViewHostSessionStart, IChatViewHostSessionStartOptions, IChatViewHostShownSession } from '../../../chat/browser/chatViewHost.js'; import { IChatService } from '../../../chat/common/chatService/chatService.js'; -import { isAgentHostSessionResource } from '../../../chat/common/chatSessionsService.js'; +import { isAgentHostSessionResource, localChatSessionType } from '../../../chat/common/chatSessionsService.js'; import { ChatAgentLocation } from '../../../chat/common/constants.js'; -import { isUntitledChatSession } from '../../../chat/common/model/chatUri.js'; +import { getChatSessionType, isUntitledChatSession } from '../../../chat/common/model/chatUri.js'; import { INewSessionTarget } from '../parts/chatView.js'; import { ISessionsManagementService } from '../../common/sessionsManagement.js'; import { IAgentsChatPartService } from './agentsChatPart.js'; @@ -71,9 +71,19 @@ export class AgentsChatViewHost implements IChatViewHost { this._generation++; } - /** The chats of sessions of the agent host, and those of the window's sessions; not local chat, nor chats of other kinds. */ + /** + * The chats of the window's sessions: a chat that a sessions provider resolves, or of a type a provider offers, such as an + * untitled new session's, and the chats of the agent host also while its providers are not registered. Not local chat, + * nor chats of other kinds, which the Chat view shows. + */ canShow(sessionResource: URI): boolean { - return isAgentHostSessionResource(sessionResource) || !!this._sessionsManagementService.getSessionForChatResource(sessionResource); + if (isAgentHostSessionResource(sessionResource) + || this._sessionsManagementService.getSessionForChatResource(sessionResource) + || this._sessionsManagementService.getSession(sessionResource)) { + return true; + } + const type = getChatSessionType(sessionResource); + return type !== localChatSessionType && this._sessionsManagementService.getAllSessionTypes().some(sessionType => (sessionType.chatSessionType ?? sessionType.id) === type); } /** New Chat, such as + in the title of the Chat view, opens a new-session composer in the chat part. */ diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts index d942dac9c775d8..b6ee82de2842eb 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts @@ -16,7 +16,7 @@ import { INotificationService } from '../../../../../../platform/notification/co import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IEditorService } from '../../../../../services/editor/common/editorService.js'; import { IWorkbenchLayoutService } from '../../../../../services/layout/browser/layoutService.js'; -import { IChat, ISession } from '../../../../../services/sessions/common/session.js'; +import { IChat, ISession, ISessionType } from '../../../../../services/sessions/common/session.js'; import { IChatWidget, IChatWidgetService } from '../../../../chat/browser/chat.js'; import { IChatModelReference, IChatService } from '../../../../chat/common/chatService/chatService.js'; import { IAgentsChatPartService } from '../../../browser/agentsView/agentsChatPart.js'; @@ -54,7 +54,7 @@ suite('AgentsChatViewHost', () => { }); const host = new AgentsChatViewHost( chatPart, - upcastPartial({ getSessionForChatResource: () => undefined, getSession: () => undefined, getSessions: () => [], onDidChangeSession: () => toDisposable(() => { }), ...options.sessionsManagementService }), + upcastPartial({ getSessionForChatResource: () => undefined, getSession: () => undefined, getAllSessionTypes: () => [], getSessions: () => [], onDidChangeSession: () => toDisposable(() => { }), ...options.sessionsManagementService }), upcastPartial({ getAllWidgets: () => options.widgets ?? [] }), upcastPartial({ isVisible: () => false }), upcastPartial({ getWorkspace: () => upcastPartial>({ folders: [] }) }), @@ -218,16 +218,25 @@ suite('AgentsChatViewHost', () => { assert.deepStrictEqual({ opened: await opening, calls }, { opened: undefined, calls: ['acquire', 'release'] }); }); - test('shows the chats of agent host sessions and of the window\'s sessions, and leaves other chats, such as local chat, to the Chat view', () => { - const known = URI.parse('copilot-cloud-agent:/known'); - const { host } = createHost({ sessionsManagementService: { getSessionForChatResource: resource => resource.toString() === known.toString() ? sessionOf(known).owner : undefined } }); + test('shows the chats of the window\'s sessions and of the types its providers offer, and leaves other chats, such as local chat, to the Chat view', () => { + const knownChat = URI.parse('known-chat:/chat-1'); + const knownSession = URI.parse('known-session:/session-1'); + const { host } = createHost({ + sessionsManagementService: { + getSessionForChatResource: resource => resource.toString() === knownChat.toString() ? sessionOf(knownChat).owner : undefined, + getSession: resource => resource.toString() === knownSession.toString() ? sessionOf(knownSession).owner.session : undefined, + getAllSessionTypes: () => [upcastPartial({ id: 'copilot-cloud', chatSessionType: 'copilot-cloud-agent' }), upcastPartial({ id: 'local' })], + }, + }); assert.deepStrictEqual({ agentHost: host.canShow(URI.parse('agent-host-copilot:/session-1')), - known: host.canShow(known), + knownChat: host.canShow(knownChat), + knownSession: host.canShow(knownSession), + untitledOfOfferedType: host.canShow(URI.parse('copilot-cloud-agent:/untitled-1')), local: host.canShow(LocalChatSessionUri.forSession('local-1')), extension: host.canShow(URI.parse('some-extension-chat:/session-1')), - }, { agentHost: true, known: true, local: false, extension: false }); + }, { agentHost: true, knownChat: true, knownSession: true, untitledOfOfferedType: true, local: false, extension: false }); }); test('New Chat opens a new-session composer in the chat part, unless chat is hidden or disabled', () => { From ecd54bd23314c2132b9dbe4851bb7237a36e1559 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:26:07 +0100 Subject: [PATCH 024/165] Worktree infra: wait for the sessions list before reading working directories, and report an explicitly opened chat as opened Copilot code review fixes: - AgentHostWorktreeLocationProvider waits for the sessions list to resolve before reading the working directories of its sessions, so the worktrees of sessions that it did not cache are not missing. - The Chat view reports a chat that the user opens or reveals as opened, also one that it shows already, such as one it restored that the user picks in a sessions list (ChatViewPane.reportOpened, from the Chat view host's openSession and revealIfShowing). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostWorktreeLocations.ts | 11 ++++++++++- .../widgetHosts/viewPane/chatViewHostService.ts | 3 +++ .../browser/widgetHosts/viewPane/chatViewPane.ts | 11 +++++++++++ .../test/browser/actions/chatMoveActions.test.ts | 1 + .../agentHost/agentHostWorktreeLocations.test.ts | 12 ++++++++++-- .../test/browser/widget/chatWidgetService.test.ts | 4 ++-- .../widgetHosts/viewPane/chatViewHostService.test.ts | 3 ++- .../widgetHosts/viewPane/chatViewPane.test.ts | 10 +++++++++- 8 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts index f9f07f83b22e96..3f15cdde56ff82 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { raceCancellation } from '../../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Schemas } from '../../../../../../base/common/network.js'; @@ -63,7 +64,15 @@ export class AgentHostWorktreeLocationProvider implements IWorkspaceFolderLocati if (!repository || token.isCancellationRequested) { return []; } - const candidates = getAgentHostSessionWorkingDirectories(this._agentSessionsService.model.sessions) + // Before the sessions list first resolves, it has only the sessions it cached: wait for the ones it has not + const model = this._agentSessionsService.model; + if (!model.resolved) { + await raceCancellation(model.resolve(undefined, undefined, 'automatic'), token); + if (token.isCancellationRequested) { + return []; + } + } + const candidates = getAgentHostSessionWorkingDirectories(model.sessions) .filter(directory => !extUriBiasedIgnorePathCase.isEqual(directory, primary)); const locations = await Promise.all(candidates.map(candidate => this._resolve(repository, candidate))); return token.isCancellationRequested ? [] : locations.filter(isDefined); diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts index b3358545b032f1..fd9906d05b0d13 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts @@ -53,6 +53,7 @@ export class ChatViewPaneHost implements IChatViewHost { if (view && !options?.preserveFocus) { view.focus(); } + view?.reportOpened(sessionResource); return view?.widget; } @@ -60,6 +61,8 @@ export class ChatViewPaneHost implements IChatViewHost { const chatView = await this.viewsService.openView(ChatViewId, !options?.preserveFocus); if (chatView) { await chatView.loadSession(sessionResource, options?.sessionTypeSelectionReason); + // Also a chat it showed already, which loading does not set again + chatView.reportOpened(sessionResource); if (!options?.preserveFocus) { chatView.focusInput(); } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index f6fb97b444ca79..f906948705e79a 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -1502,6 +1502,17 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { && sessionType === localChatSessionType; } + /** + * Reports the chat of `sessionResource` as one that the user brought up, also where the view shows it already, such as + * one it restored that the user picks in a sessions list. + */ + reportOpened(sessionResource: URI): void { + const shown = this._shownSession.get(); + if (shown?.reason === 'restore' && isEqual(shown.resource, sessionResource)) { + this._shownSession.set({ resource: sessionResource, reason: 'open' }, undefined); + } + } + /** Reports the chat that the view shows now ({@link shownSession}), as its widget's chat changes. */ private updateShownSession(): void { const shownResource = this._widget.viewModel?.sessionResource; diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatMoveActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatMoveActions.test.ts index 028b8050633834..2c04a11d900ba8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/actions/chatMoveActions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/actions/chatMoveActions.test.ts @@ -91,6 +91,7 @@ suite('Chat move actions', () => { return model; }, focusInput() { }, + reportOpened() { }, }); const viewsService = upcastPartial({ openView: mockObject()().openView.resolves(view), diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts index 9fa274f2adaed0..174b5726638b09 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts @@ -28,7 +28,7 @@ suite('AgentHostWorktreeLocations', () => { * A provider over git entries: `true` is a `.git` directory, and a string the content of a file, such as a worktree's * `.git` file or its entry's `gitdir` file in the repository. */ - function createProvider(entries: Record, sessions: readonly IAgentSession[]) { + function createProvider(entries: Record, sessions: readonly IAgentSession[], uncached: readonly IAgentSession[] = []) { const fileService = upcastPartial({ stat: async (resource: URI) => { const entry = entries[resource.path]; @@ -48,7 +48,9 @@ suite('AgentHostWorktreeLocations', () => { const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(IFileService, fileService); instantiationService.stub(ILogService, new NullLogService()); - instantiationService.stub(IAgentSessionsService, upcastPartial({ model: upcastPartial({ sessions: [...sessions] }) })); + // The sessions it cached, and those the list has only once it resolved + const model = { sessions: [...sessions], resolved: !uncached.length, resolve: async () => { model.sessions.push(...uncached); model.resolved = true; } }; + instantiationService.stub(IAgentSessionsService, upcastPartial({ model: upcastPartial(model) })); return instantiationService.createInstance(AgentHostWorktreeLocationProvider); } @@ -110,4 +112,10 @@ suite('AgentHostWorktreeLocations', () => { primary: undefined, }); }); + + test('waits for the sessions list to resolve before reading the working directories of sessions it did not cache', async () => { + const provider = createProvider(repository, [session(SessionType.AgentHostCopilot, '/work/vscode.worktrees/feature')], [session(SessionType.AgentHostCopilot, '/work/vscode.worktrees/relative')]); + const locations = await provider.provideLocations(URI.file('/work/vscode'), CancellationToken.None); + assert.deepStrictEqual(locations.map(location => location.label), ['feature', 'relative']); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatWidgetService.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatWidgetService.test.ts index b0a653dd42ac82..ef9c2aa54f5400 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatWidgetService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatWidgetService.test.ts @@ -218,7 +218,7 @@ suite('ChatWidgetService', () => { const oldWidget = createWidget(session); const emptyOldWidget = createWidget(undefined); const hostWidget = upcastPartial({}); - const view = { widget: oldWidget, focus: () => calls.push('view.focus'), loadSession: async () => { calls.push('view.loadSession'); } }; + const view = { widget: oldWidget, focus: () => calls.push('view.focus'), loadSession: async () => { calls.push('view.loadSession'); }, reportOpened: () => calls.push('view.reportOpened') }; instantiationService.stub(IEditorGroupsService, { groups: [] }); instantiationService.stub(IViewsService, { getViewWithId: (() => view) as unknown as IViewsService['getViewWithId'], @@ -275,7 +275,7 @@ suite('ChatWidgetService', () => { const widget = await service.openSession(session); - assert.deepStrictEqual({ shown: widget === oldWidget, calls }, { shown: true, calls: [`openView ${ChatViewId}`, 'view.focus'] }); + assert.deepStrictEqual({ shown: widget === oldWidget, calls }, { shown: true, calls: [`openView ${ChatViewId}`, 'view.focus', 'view.reportOpened'] }); }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts index 4408658d2fb144..abbfa90a26e95f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts @@ -43,6 +43,7 @@ suite('ChatViewHostService', () => { startNewLocalSession: async () => { calls.push('startNewLocalSession'); return undefined; }, focus: () => { calls.push('focus'); }, focusInput: () => { calls.push('focusInput'); }, + reportOpened: (resource: URI) => { calls.push(`reportOpened ${resource.toString()}`); }, beginSessionsListSuppression: () => { calls.push('suppress'); return toDisposable(() => calls.push('unsuppress')); }, }); const instantiationService = disposables.add(new TestInstantiationService()); @@ -83,7 +84,7 @@ suite('ChatViewHostService', () => { calls: [ `openView ${ChatViewId} focus=true`, `openView ${ChatViewId} focus=false`, - `openView ${ChatViewId} focus=true`, 'loadSession test:/session reason=explicitOverride', 'focusInput', + `openView ${ChatViewId} focus=true`, 'loadSession test:/session reason=explicitOverride', 'reportOpened test:/session', 'focusInput', `setPartHidden false ${Parts.AUXILIARYBAR_PART}`, `openView ${ChatViewId} focus=undefined`, 'beforeOpen', 'suppress', 'progress', 'loadSession test:/session reason=explicitOverride', 'focus', `openView ${ChatViewId} focus=undefined`, 'loadSession test:/moved reason=undefined', diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index 1519cbf2e27c49..4e888b7ec9db8c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -206,6 +206,7 @@ suite('ChatViewPane session transitions', () => { applyModel(): void; restoringSession: Promise | undefined; readonly shownSession: IObservable; + reportOpened(sessionResource: URI): void; } = Object.assign(Object.create(ChatViewPane.prototype), { _store: paneStore, _showReason: 'open', @@ -290,6 +291,11 @@ suite('ChatViewPane session transitions', () => { restored.pane.applyModel(); await restored.pane.restoringSession; const afterRestore = shown(restored); + // The user picks the restored chat that the view shows already, such as in a sessions list + restored.pane.reportOpened(previousResource); + const otherReported = shown(restored); + restored.pane.reportOpened(nextResource); + const reopened = shown(restored); Object.assign(restored.pane, { chatService: { acquireOrLoadSession: async () => savedReference() } }); await restored.pane.loadSession(savedResource); const afterLoad = shown(restored); @@ -309,8 +315,10 @@ suite('ChatViewPane session transitions', () => { await loading; await restoration; - assert.deepStrictEqual({ afterRestore, afterLoad, superseded: shown(superseded) }, { + assert.deepStrictEqual({ afterRestore, otherReported, reopened, afterLoad, superseded: shown(superseded) }, { afterRestore: '/untitled-next restore', + otherReported: '/untitled-next restore', + reopened: '/untitled-next open', afterLoad: '/saved open', superseded: '/saved open', }); From 0c1fa669ac697a3c7deb2fc41384a948857f9e56 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:26:11 +0100 Subject: [PATCH 025/165] chat: track the Chat view's shown chat only while a host replaces it Without a host that replaces it, the Chat view is always the host that showed a chat last, so the service subscribes to it only while a host is registered. Test stubs of the views service gain the view visibility event and the Chat view's shown chat. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../widgetHosts/viewPane/chatViewHostService.ts | 11 +++++++++-- .../test/browser/widget/chatWidgetService.test.ts | 3 ++- .../test/browser/openPanelChatAndGetWidget.test.ts | 3 +++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts index 7aa88f1e39e93e..170a219bfeec13 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { DeferredPromise, raceTimeout } from '../../../../../../base/common/async.js'; -import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { derived, IObservable, ISettableObservable, observableFromEvent, observableValue, runOnChange } from '../../../../../../base/common/observable.js'; import { isEqual } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -151,9 +151,11 @@ export class ChatViewHostService extends Disposable implements IChatViewHostServ this.hostInUse = observableValue(this, this.chatViewHost); this.latestHost = observableValue(this, this.chatViewHost); this.shownSession = derived(this, reader => this.latestHost.read(reader).shownSession?.read(reader)); - this._register(this.trackShownSession(this.chatViewHost)); } + /** Tracks the chats that the Chat view shows while a host replaces it; without one, the Chat view is always the latest host. */ + private readonly chatViewTracking = this._register(new MutableDisposable()); + /** Makes `host` the latest host whenever it shows another chat, such as one it restores or one opened in it directly. */ private trackShownSession(host: IChatViewHost): IDisposable { return host.shownSession ? runOnChange(host.shownSession, shown => { @@ -196,6 +198,11 @@ export class ChatViewHostService extends Disposable implements IChatViewHostServ this.hostInUse.set(host, undefined); this.latestHost.set(host, undefined); this._replaced.set(this.registrations.length > 0, undefined); + if (this.registrations.length === 0) { + this.chatViewTracking.clear(); + } else if (!this.chatViewTracking.value) { + this.chatViewTracking.value = this.trackShownSession(this.chatViewHost); + } } reveal(preserveFocus?: boolean, options?: IChatViewHostRevealOptions): Promise { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatWidgetService.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatWidgetService.test.ts index 4ef0d656f53c79..0f21e32560dcc7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatWidgetService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatWidgetService.test.ts @@ -218,9 +218,10 @@ suite('ChatWidgetService', () => { const oldWidget = createWidget(session); const emptyOldWidget = createWidget(undefined); const hostWidget = upcastPartial({}); - const view = { widget: oldWidget, focus: () => calls.push('view.focus'), loadSession: async () => { calls.push('view.loadSession'); } }; + const view = { widget: oldWidget, shownSession: constObservable(undefined), focus: () => calls.push('view.focus'), loadSession: async () => { calls.push('view.loadSession'); } }; instantiationService.stub(IEditorGroupsService, { groups: [] }); instantiationService.stub(IViewsService, { + onDidChangeViewVisibility: Event.None, getViewWithId: (() => view) as unknown as IViewsService['getViewWithId'], openView: (async (id: string) => { calls.push(`openView ${id}`); return view; }) as unknown as IViewsService['openView'], }); diff --git a/src/vs/workbench/contrib/mcp/test/browser/openPanelChatAndGetWidget.test.ts b/src/vs/workbench/contrib/mcp/test/browser/openPanelChatAndGetWidget.test.ts index ea2ce8b9936e09..e17faf721fee41 100644 --- a/src/vs/workbench/contrib/mcp/test/browser/openPanelChatAndGetWidget.test.ts +++ b/src/vs/workbench/contrib/mcp/test/browser/openPanelChatAndGetWidget.test.ts @@ -12,6 +12,7 @@ import { IChatViewHost, IChatViewHostService } from '../../../chat/browser/chatV import { ChatViewHostService } from '../../../chat/browser/widgetHosts/viewPane/chatViewHostService.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; +import { Event } from '../../../../../base/common/event.js'; import { IViewDescriptorService } from '../../../../common/views.js'; import { IWorkbenchLayoutService } from '../../../../services/layout/browser/layoutService.js'; import { IProgressService } from '../../../../../platform/progress/common/progress.js'; @@ -27,6 +28,8 @@ suite('openPanelChatAndGetWidget', () => { const calls: string[] = []; const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IViewsService, upcastPartial({ + onDidChangeViewVisibility: Event.None, + getViewWithId: (() => null) as IViewsService['getViewWithId'], openView: (async (id: string, focus?: boolean) => { calls.push(`openView ${id} focus=${focus}`); return upcastPartial({ widget: chatViewWidget }); }) as IViewsService['openView'], })); instantiationService.stub(IViewDescriptorService, {}); From de3f079d3f6334f83551c2227e8f8c0a64ba1f0b Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:28:17 +0100 Subject: [PATCH 026/165] Sessions: the workspace picker's current folder says it is current to screen readers The picker's accessible names do not include an item's description, so the folder that the window's folder is at now was announced by its name only. Its accessible name now includes "Current" (Copilot code review). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/test/browser/sessionWorkspacePicker.test.ts | 5 ++++- .../contrib/sessions/browser/chat/sessionWorkspacePicker.ts | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index 209a3436d559be..35780b99d4e08c 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -8934,12 +8934,15 @@ suite('WorkspacePicker - Allowed folders', () => { : `${item.label}${item.description ? ` (${item.description})` : ''}${item.item?.checked ? ' *' : ''}`); const atWorktree = items(); + const ariaLabels = picker.getItems().filter(item => item.item?.folderUri).map(item => item.item?.ariaLabel ?? null); current = '/local/vscode'; await picker.dispatchFolder(URI.file('/local/vscode'), 'local-1'); const atFolder = items(); - assert.deepStrictEqual({ atWorktree, atFolder }, { + assert.deepStrictEqual({ atWorktree, ariaLabels, atFolder }, { atWorktree: ['Chat', '---', 'New Worktree', '--- Folder', 'local/vscode', '--- Worktrees', 'b (Current) *', 'a (feature-a)'], + // Screen readers hear where the window's folder is now, apart from the checked choice + ariaLabels: [null, 'b, Current', null], atFolder: ['Chat', '---', 'New Worktree', '--- Folder', 'local/vscode (Current) *', '--- Worktrees', 'b (feature-b)', 'a (feature-a)'], }); }); diff --git a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts index 90cc2e666499c1..6f0211b22f2123 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts +++ b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts @@ -2925,6 +2925,8 @@ export class WorkspacePicker extends Disposable { folderUri, providerId: resolved.providerId, checked: (!newWorktreeSelected && this._isSelectedFolder(folderUri)) || undefined, + // Its accessible name says where the window's folder is now, which its check does not + ariaLabel: current ? localize('workspacePicker.currentFolderAriaLabel', "{0}, {1}", presentation?.label ?? resolved.workspace.label, getCurrentFolderLocationDescription()) : undefined, }, }, }); From 1a6ddcfa5cc4b0865e923686f56707b33cb548ee Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:29:28 +0100 Subject: [PATCH 027/165] Workbench: the chat part follows its anchor view as the view deregisters, and keeps the focus as it moves Copilot code review fixes: - The chat part also moves as its anchor view deregisters while its container stays, back to the primary side bar's side. - Moving the chat part to the other side of the editor takes it out of the DOM and back: the chat part gets back the focus it had. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/workbench/browser/layout.ts | 8 +++++- src/vs/workbench/test/browser/layout.test.ts | 28 +++++++++++++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index 3424609d74b77a..f835e902b249b6 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -1812,7 +1812,8 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.viewDescriptorService.onDidChangeContainer, this.viewDescriptorService.onDidChangeLocation, this.viewDescriptorService.onDidChangeViewContainers, - Registry.as(ViewExtensions.ViewsRegistry).onViewsRegistered + Registry.as(ViewExtensions.ViewsRegistry).onViewsRegistered, + Registry.as(ViewExtensions.ViewsRegistry).onViewsDeregistered )(() => this.updateChatPartPosition())); } @@ -2574,6 +2575,8 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.chatPartPosition = this.computeChatPartPosition(); + // Moving a view in the grid takes it out of the DOM and back: what had focus in it gets it again + const hadFocus = this.hasFocus(Parts.CHAT_PART); const visible = !this.chatPartHidden; const width = visible ? this.workbenchGrid.getViewSize(this.chatPartView).width : undefined; const sizing = width ?? Sizing.Invisible(this.workbenchGrid.getViewCachedVisibleSize(this.chatPartView) ?? this.chatPartView.minimumWidth); @@ -2581,6 +2584,9 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi if (width !== undefined) { this.workbenchGrid.resizeView(this.chatPartView, { width, height: this.workbenchGrid.getViewSize(this.chatPartView).height }); } + if (hadFocus) { + this.focusPart(Parts.CHAT_PART); + } } /** The layout of the chat part as the window last left it; not shown, at a default size, without one. */ diff --git a/src/vs/workbench/test/browser/layout.test.ts b/src/vs/workbench/test/browser/layout.test.ts index 48890111b6c0e5..b7a43cbaca1dc9 100644 --- a/src/vs/workbench/test/browser/layout.test.ts +++ b/src/vs/workbench/test/browser/layout.test.ts @@ -197,11 +197,13 @@ suite('Workbench layout chat part', () => { const initialization = new Map([['sideBar.size', 250], ['auxiliaryBar.size', 260], ['panel.size', 200]]); const anchor = { location: configuration.anchor ?? null }; let stored = saved ? saved.stored : configuration.chat && configuration.chat !== 'noLayout' ? JSON.stringify({ version: 1, ...configuration.chat }) : undefined; + const focus = { chat: false, focused: [] as string[] }; const layout = store.add(new class extends Layout { override isVisible(part: Parts, targetWindow?: Window): boolean { return part === Parts.TITLEBAR_PART || super.isVisible(part, targetWindow); } - override hasFocus() { return false; } + override hasFocus(part: Parts) { return part === Parts.CHAT_PART && focus.chat; } + override focusPart(part: Parts) { focus.focused.push(part.split('.').pop()!); } override isFloatingPanelsEnabled() { return false; } protected override getPart(id: Parts) { return views.get(id)!; } }(mainWindow.document.body)); @@ -228,7 +230,7 @@ suite('Workbench layout chat part', () => { (Reflect.get(layout, 'saveChatPartLayout') as () => void).call(layout); return { runtime, stored }; }; - return { layout, views, descriptor, save, anchor }; + return { layout, views, descriptor, save, anchor, focus }; } /** The parts of `node`, nested as the grid nests them, marking hidden parts. */ @@ -304,13 +306,13 @@ suite('Workbench layout chat part', () => { }); function createGridLayout(configuration: ILayoutConfiguration, saved?: ISavedLayout) { - const { layout, views, descriptor, save, anchor } = createLayout(configuration, saved); + const { layout, views, descriptor, save, anchor, focus } = createLayout(configuration, saved); const grid = store.add(SerializableGrid.deserialize(descriptor, { fromJSON: ({ type }: { type: Parts }) => views.get(type)! }, { proportionalLayout: false })); append(layout.mainContainer, grid.element); grid.layout(1600, 900); Object.assign(layout, { workbenchGrid: grid }); const middle = () => shape((grid.serialize().root.data as ISerializedNode[])[2]); - return { layout, grid, middle, save, anchor, views }; + return { layout, grid, middle, save, anchor, views, focus }; } test('the visible neighbors of the parts beside a chat part are the chat part, as long as it shows', () => { @@ -512,4 +514,22 @@ suite('Workbench layout chat part', () => { restored: ['activitybar', 'sidebar', [['editor', 'chat'], 'panel'], 'auxiliarybar'], }); }); + + test('the chat part keeps the focus as it moves to its anchor view\'s side, and follows the view as it is deregistered', () => { + const { layout, middle, anchor, focus } = createGridLayout({ sideBar: Position.LEFT, panel: Position.BOTTOM, alignment: 'center', chat: { visible: true, size: 400 }, anchor: ViewContainerLocation.AuxiliaryBar }); + const moveAnchor = (location: ViewContainerLocation | null) => { anchor.location = location; (Reflect.get(layout, 'updateChatPartPosition') as () => void).call(layout); }; + focus.chat = true; + moveAnchor(ViewContainerLocation.Sidebar); + const focused = [...focus.focused]; + focus.chat = false; + moveAnchor(ViewContainerLocation.AuxiliaryBar); + // As the view deregisters, it is no view the layout knows + moveAnchor(null); + + assert.deepStrictEqual({ focused, focusedAfter: focus.focused, deregistered: middle() }, { + focused: ['chat'], + focusedAfter: ['chat'], + deregistered: ['activitybar', 'sidebar', [['chat', 'editor'], 'panel'], 'auxiliarybar'], + }); + }); }); From 82469f84068b62bfb08f8b7f3508e03ce0e5351d Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:29:30 +0100 Subject: [PATCH 028/165] chat: New Chat of the Chat view starts a provided composer over A provided composer gains newChat(), which starts it over without its input, attachments and target. New Chat of the Chat view starts it over, and is the composer's while it shows in place of the chat; otherwise New Chat clears the chat, in whose place the composer then shows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../widgetHosts/viewPane/chatViewHostService.ts | 4 ++++ .../browser/widgetHosts/viewPane/chatViewPane.ts | 16 ++++++++++++++++ .../widgetHosts/viewPane/chatViewSessionsList.ts | 2 ++ .../widgetHosts/viewPane/chatViewPane.test.ts | 12 ++++++++++++ 4 files changed, 34 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts index 170a219bfeec13..956431a4f387a3 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts @@ -67,6 +67,10 @@ export class ChatViewPaneHost implements IChatViewHost { return chatView?.widget; } + newChat(): boolean { + return this.viewsService.isViewVisible(ChatViewId) && !!this.viewsService.getViewWithId(ChatViewId)?.newChat(); + } + async toggle(): Promise { const visible = !this.viewsService.isViewVisible(ChatViewId); this.updatePartVisibility(this.viewDescriptorService.getViewLocationById(ChatViewId), visible); diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 065a1a7eea50ad..302df224b1b9a1 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -900,6 +900,22 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } } + /** + * New Chat of the Chat view: the provided composer starts over, and takes New Chat while it shows in place of the chat. + * Returns whether it took it; otherwise New Chat clears the chat, in whose place the composer then shows. + */ + newChat(): boolean { + if (!this.providedComposer) { + return false; + } + this.providedComposer.newChat(); + if (!this.composerShown) { + return false; + } + this.providedComposer.focus(); + return true; + } + get agentSessionsControl(): AgentSessionsControl | undefined { return this.sessionsControl instanceof AgentSessionsControl ? this.sessionsControl : undefined; } private sessionsListRevealCount = 0; diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewSessionsList.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewSessionsList.ts index 2608262efc79e2..1585367d18fb37 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewSessionsList.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewSessionsList.ts @@ -34,6 +34,8 @@ export interface IChatViewSessionsComposer extends IDisposable { readonly height: IObservable; layout(width: number, height: number): void; focus(): void; + /** Starts over with a new chat, as New Chat of the Chat view does: without the input, the attachments and the target it had. */ + newChat(): void; } /** A sessions list of the Chat view, with how the Chat view shows it. */ diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index 9bb54bee77b0d4..fc5748e8613c1e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -127,6 +127,18 @@ suite('ChatViewPane sessions refresh', () => { assert.deepStrictEqual({ states, withoutComposer: withoutComposer.composerShown }, { states: [true, false, true], withoutComposer: false }); }); + test('New Chat starts the provided composer over, and is the composer\'s while it shows in place of the chat', () => { + const calls: string[] = []; + const composer = { newChat: () => calls.push('newChat'), focus: () => calls.push('focus') }; + const newChat = Reflect.get(ChatViewPane.prototype, 'newChat') as (this: { providedComposer: typeof composer | undefined; composerShown: boolean }) => boolean; + const results = { + shown: newChat.call({ providedComposer: composer, composerShown: true }), + hidden: newChat.call({ providedComposer: composer, composerShown: false }), + withoutComposer: newChat.call({ providedComposer: undefined, composerShown: false }), + }; + assert.deepStrictEqual({ results, calls }, { results: { shown: true, hidden: false, withoutComposer: false }, calls: ['newChat', 'focus', 'newChat'] }); + }); + test('the Chat view takes the sessions list of the provider registered last, and its own once none is', () => { const service = new ChatViewSessionsListService(); const provider = (name: string) => upcastPartial({ create: () => { throw new Error(name); } }); From c891b9bf234c2e9455f7e64a640f5c968b066cbd Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:31:27 +0100 Subject: [PATCH 029/165] Editor window: hide the worktree folder chip with AI features off Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostChatInputPicker.contribution.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts index 5e7caa7628afa2..b16bc4869cfb55 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts @@ -75,6 +75,7 @@ export class OpenAgentHostFolderPickerAction extends Action2 { order: 1.1, // With worktree support, a window with a single local folder offers New Worktree, the folder and its worktrees when: ContextKeyExpr.and( + ChatContextKeys.enabled, ChatContextKeyExprs.isAgentHostSession, ContextKeyExpr.equals(`config.${ChatConfiguration.EditorWindowEnableWorktree}`, true), WorkbenchStateContext.isEqualTo('folder'), From 753dba50e02de4344932452187f159417b1d7db9 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:33:01 +0100 Subject: [PATCH 030/165] Editor window sessions: review fixes (workspace trust, legacy resources, new sessions, composer hold) - canOpenSession asks for the trust of every folder a session works in before it opens, as the Agents window does, instead of opening it unconditionally. The check, with the trust that worktrees inherit from their base repository, is shared as requestSessionWorkspaceTrust. - Opening a session by its resource resolves it first (resolveSessionResource, 'open'), so a provider redirects a superseded or adopted resource, such as a legacy one. - New Session forwards the model, the mode and whether it requires a Dev Container to the presenter's composer. - Without a presenter, New Session starts a new chat in the Chat view, rather than revealing the chat it shows, which the next request would go to. - The providers stay held while a composer holder, such as the chat part, has a composer with input, also once it no longer presents the sessions (registerComposerHolder). - The sessions management test of the Agents window ignores the root host that editor windows register. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/sessionsManagementService.test.ts | 4 +- .../editorWindowSessionsNavigationHost.ts | 14 ++++- .../agentsView/editorWindowSessionsService.ts | 16 +++-- .../editorWindowSessionsPresenter.ts | 61 ++++++++++++++----- .../sessions/browser/sessionsService.ts | 43 ++----------- .../agentsViewProviders.contribution.ts | 4 +- .../agentsViewProviders.contribution.test.ts | 7 +-- .../editorWindowSessionsRuntime.test.ts | 35 +++++++++++ .../sessions/browser/worktreeTrust.ts | 36 ++++++++++- 9 files changed, 153 insertions(+), 67 deletions(-) diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index 7c55f7514d57bc..ca8f7b036577a4 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -88,6 +88,7 @@ import { SessionHostService } from '../../../../../workbench/contrib/sessions/br import { createMainWindowInstantiationService, getMainWindowHost, ISessionHost, ISessionHostService, SessionHost, SessionHostKind } from '../../../../../workbench/services/sessions/browser/sessionHost.js'; import { IEditorGroupsService } from '../../../../../workbench/services/editor/common/editorGroupsService.js'; import { getSingletonServiceDescriptors } from '../../../../../platform/instantiation/common/extensions.js'; +import { EditorWindowMainSessionHost } from '../../../../../workbench/contrib/sessions/browser/agentsView/editorWindowMainSessionHost.js'; import { InstantiationService } from '../../../../../platform/instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { ISessionPaneContent } from '../../../../../workbench/services/sessions/common/sessionPane.js'; @@ -864,7 +865,8 @@ suite('SessionsManagementService', () => { disposable: (view.gridHost as Partial).dispose !== undefined, hosts: sessionHostService.hosts.get().filter(host => host.kind === SessionHostKind.MainWindow).length, injected: injected === view.gridHost, - rootRegistration: getSingletonServiceDescriptors().some(([id]) => id === ISessionHost), + // Editor windows register their own root host, which shows what a feature binds; the Agents window registers none + rootRegistration: getSingletonServiceDescriptors().some(([id, descriptor]) => id === ISessionHost && descriptor.ctor !== EditorWindowMainSessionHost), rootInjection, shownBefore, shownAfter: view.gridHost.session.get()?.sessionId, diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsNavigationHost.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsNavigationHost.ts index a6332de209d2bc..bf6f09d5bec412 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsNavigationHost.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsNavigationHost.ts @@ -13,6 +13,8 @@ import { ISessionsNavigationService } from '../sessionsNavigationService.js'; import type { IOpenChatOptions, IOpenSessionOptions } from '../sessionsService.js'; import { IEditorWindowSessionsPresenterService } from '../editorWindow/editorWindowSessionsPresenter.js'; import { IAgentsViewProvidersService, waitForAgentsViewSession } from './agentsViewProviders.js'; +import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService } from '../../../../../platform/workspace/common/workspaceTrust.js'; +import { requestSessionWorkspaceTrust } from '../../../../services/sessions/browser/worktreeTrust.js'; /** * The navigation of an editor window: sessions and chats open where its presenter shows them (see @@ -30,6 +32,8 @@ export class EditorWindowSessionsNavigationHost implements ISessionsNavigationSe @ISessionHost private readonly _mainWindowHost: ISessionHost, @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @IAgentsViewProvidersService private readonly _providers: IAgentsViewProvidersService, + @IWorkspaceTrustManagementService private readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService, + @IWorkspaceTrustRequestService private readonly _workspaceTrustRequestService: IWorkspaceTrustRequestService, ) { } /** What shows the chats of sessions: the chat part, while a feature shows them there, or the Chat view. */ @@ -45,7 +49,9 @@ export class EditorWindowSessionsNavigationHost implements ISessionsNavigationSe // Its session is known only while the providers are held: until it shows, such as in the chat part, which holds them then const hold = this._providers.acquire(); try { - const session = await waitForAgentsViewSession(this._sessionsManagementService, sessionResource, () => this._sessionsManagementService.getSession(sessionResource)); + // Providers redirect superseded resources, such as of a session they adopted, and leave unfamiliar ones unchanged + const resolved = await this._sessionsManagementService.resolveSessionResource(sessionResource, 'open'); + const session = await waitForAgentsViewSession(this._sessionsManagementService, resolved, () => this._sessionsManagementService.getSession(resolved)); if (session) { await this._presenter.openChat(session, undefined, { preserveFocus: options?.preserveFocus }); } @@ -66,8 +72,10 @@ export class EditorWindowSessionsNavigationHost implements ISessionsNavigationSe await this._presenter.openChat(session, chatResource, { preserveFocus: options?.preserveFocus, sideBySide: true }); } - async canOpenSession(_session: ISession): Promise { - return true; + /** Whether the user trusts every folder the session works in, as the Agents window asks before it opens a session. */ + async canOpenSession(session: ISession): Promise { + const workspace = session.workspace.get(); + return requestSessionWorkspaceTrust(workspace, () => workspace && this._sessionsManagementService.getRemoteHostLabel(workspace, session.providerId), this._workspaceTrustManagementService, this._workspaceTrustRequestService); } focusActiveSession(): void { diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsService.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsService.ts index e1743a0295a92b..745f1a902d57e7 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsService.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsService.ts @@ -13,7 +13,8 @@ import { localize } from '../../../../../nls.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; -import { IWorkspaceTrustRequestService } from '../../../../../platform/workspace/common/workspaceTrust.js'; +import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService } from '../../../../../platform/workspace/common/workspaceTrust.js'; +import { requestSessionWorkspaceTrust } from '../../../../services/sessions/browser/worktreeTrust.js'; import { ISessionGridPane } from '../../../../services/sessions/browser/sessionGridPane.js'; import { ISessionHost } from '../../../../services/sessions/browser/sessionHost.js'; import { IChat, ISession } from '../../../../services/sessions/common/session.js'; @@ -48,6 +49,7 @@ export class EditorWindowSessionsService extends Disposable implements ISessions @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, @IWorkspaceTrustRequestService private readonly _workspaceTrustRequestService: IWorkspaceTrustRequestService, + @IWorkspaceTrustManagementService private readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @ILogService private readonly _logService: ILogService, @ISessionOpenTelemetryService private readonly _sessionOpenTelemetryService: ISessionOpenTelemetryService, @@ -127,7 +129,9 @@ export class EditorWindowSessionsService extends Disposable implements ISessions // Its session is known only while the providers are held: until it shows, such as in the chat part, which holds them then const hold = this._providers.acquire(); try { - const session = await waitForAgentsViewSession(this._sessionsManagementService, sessionResource, () => this._sessionsManagementService.getSession(sessionResource)); + // Providers redirect superseded resources, such as of a session they adopted, and leave unfamiliar ones unchanged + const resolved = await this._sessionsManagementService.resolveSessionResource(sessionResource, 'open'); + const session = await waitForAgentsViewSession(this._sessionsManagementService, resolved, () => this._sessionsManagementService.getSession(resolved)); if (session) { await this._openSession(session, options); } @@ -168,8 +172,10 @@ export class EditorWindowSessionsService extends Disposable implements ISessions await this._presenter.openChat(session, chatResource, { preserveFocus: options?.preserveFocus, sideBySide: true }); } - async canOpenSession(): Promise { - return true; + /** Whether the user trusts every folder the session works in, as the Agents window asks before it opens a session. */ + async canOpenSession(session: ISession): Promise { + const workspace = session.workspace.get(); + return requestSessionWorkspaceTrust(workspace, () => workspace && this._sessionsManagementService.getRemoteHostLabel(workspace, session.providerId), this._workspaceTrustManagementService, this._workspaceTrustRequestService); } async openChat(session: ISession, chatUri: URI, options?: IOpenChatOptions): Promise { @@ -233,7 +239,7 @@ export class EditorWindowSessionsService extends Disposable implements ISessions const draftSlot = options?.draftSlot && options.draftSlot !== this._sessionsManagementService.defaultNewSessionSlot ? options.draftSlot : undefined; if (!draftSlot) { const target = options?.folderUri - ? { folderUri: options.folderUri, providerId: options.providerId, sessionTypeId: options.sessionTypeId } + ? { folderUri: options.folderUri, providerId: options.providerId, sessionTypeId: options.sessionTypeId, modelId: options.modelId, modeId: options.modeId, requireDevContainer: options.requireDevContainer } : getEditorWindowComposerTarget(this._workspaceContextService, this._editorService); await this._presenter.openComposer(target, { sideBySide: options?.toSide }); return { session: undefined, trustDeclined: false }; diff --git a/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsPresenter.ts b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsPresenter.ts index 8138764087cef9..cd5aab53032819 100644 --- a/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsPresenter.ts +++ b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsPresenter.ts @@ -6,11 +6,13 @@ import { Disposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { constObservable, derived, IObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { EditorResourceAccessor, SideBySideEditor } from '../../../../common/editor.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IChat, ISession } from '../../../../services/sessions/common/session.js'; +import { ACTION_ID_NEW_CHAT } from '../../../chat/browser/actions/chatActions.js'; import { IChatViewHostService } from '../../../chat/browser/chatViewHost.js'; import { INewSessionTarget } from '../parts/chatView.js'; @@ -48,14 +50,20 @@ export interface IEditorWindowSessionsPresenter { /** Whether it shows `chatResource`. */ isShowing(chatResource: URI): boolean; - /** Whether it shows a new-session composer that has not sent its session yet, whose draft is a provider's. */ + /** Moves focus into what shows the active chat. */ + focus(): void; +} + +/** + * What holds new-session composers, whose drafts are sessions of the window's providers, such as the chat part: also while + * it no longer presents the window's sessions, as long as its composers live. + */ +export interface IEditorWindowComposerHolder { + /** Whether it has a new-session composer that has not sent its session yet. */ hasComposer(): boolean; - /** Closes the new-session composers with nothing typed or attached, and no send under way. */ + /** Closes its new-session composers with nothing typed or attached, and no send under way. */ closeIdleComposers(): Promise; - - /** Moves focus into what shows the active chat. */ - focus(): void; } export const IEditorWindowSessionsPresenterService = createDecorator('editorWindowSessionsPresenterService'); @@ -68,6 +76,15 @@ export interface IEditorWindowSessionsPresenterService { /** Shows the chats of sessions with `presenter` until the returned disposable is disposed; the one before shows them then. */ registerPresenter(presenter: IEditorWindowSessionsPresenter): IDisposable; + + /** Counts the composers of `holder` until the returned disposable is disposed (see {@link hasComposer}). */ + registerComposerHolder(holder: IEditorWindowComposerHolder): IDisposable; + + /** Whether a registered holder has a new-session composer that has not sent its session yet. */ + hasComposer(): boolean; + + /** Closes the idle new-session composers of every registered holder. */ + closeIdleComposers(): Promise; } /** The Chat view, whose host shows one chat at a time, as presenter of the sessions of an editor window. */ @@ -75,14 +92,19 @@ class ChatViewSessionsPresenter implements IEditorWindowSessionsPresenter { readonly activeChat: IObservable = constObservable(undefined); - constructor(private readonly _chatViewHostService: IChatViewHostService) { } + constructor( + private readonly _chatViewHostService: IChatViewHostService, + private readonly _commandService: ICommandService, + ) { } async openChat(session: ISession, chatResource: URI | undefined, options?: IEditorWindowPresenterOpenOptions): Promise { await this._chatViewHostService.openSession(chatResource ?? session.mainChat.get().resource, { preserveFocus: options?.preserveFocus }); } + /** A new chat in the Chat view, as its New Chat starts one: the next request does not go to the chat it showed. */ async openComposer(_target: INewSessionTarget | undefined, options?: IEditorWindowPresenterOpenOptions): Promise { await this._chatViewHostService.reveal(options?.preserveFocus); + await this._commandService.executeCommand(ACTION_ID_NEW_CHAT); } async closeChat(): Promise { } @@ -91,12 +113,6 @@ class ChatViewSessionsPresenter implements IEditorWindowSessionsPresenter { return false; } - hasComposer(): boolean { - return false; - } - - async closeIdleComposers(): Promise { } - focus(): void { void this._chatViewHostService.reveal(false); } @@ -108,10 +124,14 @@ export class EditorWindowSessionsPresenterService extends Disposable implements private readonly _registered = observableValue(this, []); readonly presenter: IObservable; + private readonly _composerHolders = new Set(); - constructor(@IChatViewHostService chatViewHostService: IChatViewHostService) { + constructor( + @IChatViewHostService chatViewHostService: IChatViewHostService, + @ICommandService commandService: ICommandService, + ) { super(); - const chatView = new ChatViewSessionsPresenter(chatViewHostService); + const chatView = new ChatViewSessionsPresenter(chatViewHostService, commandService); this.presenter = derived(this, reader => this._registered.read(reader).at(-1) ?? chatView); } @@ -119,6 +139,19 @@ export class EditorWindowSessionsPresenterService extends Disposable implements this._registered.set([...this._registered.get(), presenter], undefined); return toDisposable(() => this._registered.set(this._registered.get().filter(candidate => candidate !== presenter), undefined)); } + + registerComposerHolder(holder: IEditorWindowComposerHolder): IDisposable { + this._composerHolders.add(holder); + return toDisposable(() => this._composerHolders.delete(holder)); + } + + hasComposer(): boolean { + return [...this._composerHolders].some(holder => holder.hasComposer()); + } + + async closeIdleComposers(): Promise { + await Promise.all([...this._composerHolders].map(holder => holder.closeIdleComposers())); + } } /** Where a new-session composer of an editor window aims: the folder of the active editor, or else the window's first folder. */ diff --git a/src/vs/workbench/contrib/sessions/browser/sessionsService.ts b/src/vs/workbench/contrib/sessions/browser/sessionsService.ts index c2d9b4b7f6ea29..785079731f3073 100644 --- a/src/vs/workbench/contrib/sessions/browser/sessionsService.ts +++ b/src/vs/workbench/contrib/sessions/browser/sessionsService.ts @@ -24,7 +24,7 @@ import { ChatInteractivity, ChatOriginKind, IChat, ISession, isSessionCreated, S import { ICreateNewChatInSessionOptions, ICreateNewSessionOptions, inheritableSessionTarget, INewSessionDraftSlot, IRecentlyOpenedSessions, ISessionsChangeEvent, ISessionsManagementService, IToggleSessionStickinessEvent } from '../common/sessionsManagement.js'; import { IChatMovedEvent } from '../common/sessionsProvider.js'; import { ISessionsProvidersService } from './sessionsProvidersService.js'; -import { ensureSessionWorktreesTrusted } from '../../../services/sessions/browser/worktreeTrust.js'; +import { requestSessionWorkspaceTrust } from '../../../services/sessions/browser/worktreeTrust.js'; import { ClosedItemHistory } from './closedItemHistory.js'; import { SessionNavigationIntent, SessionsNavigation } from './sessionNavigation.js'; import { SessionsRecencyHistory } from '../../../services/sessions/browser/sessionsRecencyHistory.js'; @@ -1578,43 +1578,12 @@ export class SessionsService extends Disposable implements ISessionsService { if (this._activeGridSession()?.sessionId === session.sessionId) { return true; } + // Every folder the session operates in must be trusted before it opens, after its worktrees inherited the trust of + // their base repository. Run from this imperative open path (not the reactive mount, whose equivalent step only runs + // once the session is active), the prompt fires once per open and cannot loop; declining any folder leaves the + // current session (or empty new-session slot) untouched. const workspace = session.workspace.get(); - // A session that doesn't require workspace trust (virtual/cloud/quick-chat), - // or whose workspace metadata has not hydrated yet, opens without a check; a - // folder-less workspace has nothing to gate. - if (!workspace?.requiresWorkspaceTrust) { - return true; - } - // Inherit trust for any isolated worktree VS Code created off a base - // repository the user already trusts, before checking folders — so opening - // a worktree session does not prompt for a folder whose provenance is - // already trusted. This runs here (the imperative open path) because the - // reactive mount's equivalent step only runs once the session is active, - // i.e. after this gate. - await ensureSessionWorktreesTrusted(workspace, this.workspaceTrustManagementService); - // Every folder the session operates in must be trusted before it opens, not - // just the primary one: the agent — and its tasks, terminals and other - // tooling — can run against any of the session's working directories, so we - // make no assumptions about the non-primary folders being harmless. Check - // all in parallel (fast path when already trusted), then surface VS Code's - // standard workspace-trust dialog for each untrusted folder in turn. - // Declining any leaves the current session (or empty new-session slot) - // untouched. Run from this imperative open path (not the reactive mount), - // the prompt fires once per open and cannot loop. - const folders = workspace.folders.map(folder => folder.workingDirectory); - const trustInfos = await Promise.all(folders.map(folder => this.workspaceTrustManagementService.getUriTrustInfo(folder))); - const untrustedFolders = folders.filter((_, index) => !trustInfos[index].trusted); - for (const folder of untrustedFolders) { - const trusted = await this.workspaceTrustRequestService.requestResourcesTrust({ - uri: folder, - message: localize('sessionsService.trustFolderMessage', "An agent session will be able to read files, run commands, and make changes in this folder."), - remoteHostLabel: this.sessionsManagementService.getRemoteHostLabel(workspace, session.providerId), - }); - if (!trusted) { - return false; - } - } - return true; + return requestSessionWorkspaceTrust(workspace, () => workspace && this.sessionsManagementService.getRemoteHostLabel(workspace, session.providerId), this.workspaceTrustManagementService, this.workspaceTrustRequestService); } async openSessionToSide(session: ISession, options?: IOpenSessionOptions & { chatResource?: URI; referenceSessionId?: string }): Promise { diff --git a/src/vs/workbench/contrib/sessions/providers/agentsView/browser/agentsViewProviders.contribution.ts b/src/vs/workbench/contrib/sessions/providers/agentsView/browser/agentsViewProviders.contribution.ts index 66e69626c11e03..3b193285c7cc3a 100644 --- a/src/vs/workbench/contrib/sessions/providers/agentsView/browser/agentsViewProviders.contribution.ts +++ b/src/vs/workbench/contrib/sessions/providers/agentsView/browser/agentsViewProviders.contribution.ts @@ -87,11 +87,11 @@ export class AgentsViewProvidersService extends Disposable implements IAgentsVie return; } // A composer the user left without typing anything goes, so the providers can go too - await this._presenterService.presenter.get().closeIdleComposers(); + await this._presenterService.closeIdleComposers(); if (this._store.isDisposed || this._holds > 0 || this._release.value) { return; } - if (this._presenterService.presenter.get().hasComposer()) { + if (this._presenterService.hasComposer()) { this._releaseLater(); } else { this._registration.clear(); diff --git a/src/vs/workbench/contrib/sessions/providers/agentsView/test/browser/agentsViewProviders.contribution.test.ts b/src/vs/workbench/contrib/sessions/providers/agentsView/test/browser/agentsViewProviders.contribution.test.ts index fe570aad37c6c3..a627fce09b3cbd 100644 --- a/src/vs/workbench/contrib/sessions/providers/agentsView/test/browser/agentsViewProviders.contribution.test.ts +++ b/src/vs/workbench/contrib/sessions/providers/agentsView/test/browser/agentsViewProviders.contribution.test.ts @@ -4,14 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { constObservable } from '../../../../../../../base/common/observable.js'; import { IDisposable } from '../../../../../../../base/common/lifecycle.js'; import { isWeb } from '../../../../../../../base/common/platform.js'; import { upcastPartial } from '../../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { AGENTS_VIEW_PROVIDERS_RELEASE_DELAY, AgentsViewProvidersService } from '../../browser/agentsViewProviders.contribution.js'; -import { IEditorWindowSessionsPresenter, IEditorWindowSessionsPresenterService } from '../../../../browser/editorWindow/editorWindowSessionsPresenter.js'; +import { IEditorWindowComposerHolder, IEditorWindowSessionsPresenterService } from '../../../../browser/editorWindow/editorWindowSessionsPresenter.js'; import { timeout } from '../../../../../../../base/common/async.js'; import { runWithFakedTimers } from '../../../../../../../base/test/common/virtualScheduling/index.js'; @@ -32,7 +31,7 @@ suite('AgentsViewProvidersService', () => { const instantiationService = upcastPartial({ invokeFunction: (fn: (accessor: ServicesAccessor, ...args: TS) => R, ...args: TS): R => fn(upcastPartial({ get: (): T => providersInstantiationService as T }), ...args), }); - const presenter = upcastPartial({ + const holder = upcastPartial({ hasComposer: () => composer.open, // Closing an editor is asynchronous, as its group confirms it first closeIdleComposers: async () => { @@ -43,7 +42,7 @@ suite('AgentsViewProvidersService', () => { } }, }); - return disposables.add(new AgentsViewProvidersService(instantiationService, upcastPartial({ presenter: constObservable(presenter) }))); + return disposables.add(new AgentsViewProvidersService(instantiationService, upcastPartial({ hasComposer: () => holder.hasComposer(), closeIdleComposers: () => holder.closeIdleComposers() }))); } test('registers the providers while held, and releases them a while after nothing holds them', () => runWithFakedTimers({}, async () => { diff --git a/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts b/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts index 8ee013ad009101..1a371b0a65c3af 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts @@ -13,6 +13,10 @@ import { ILifecycleService } from '../../../../../services/lifecycle/common/life import { IViewsService } from '../../../../../services/views/common/viewsService.js'; import { IAgentsViewProvidersService } from '../../../browser/agentsView/agentsViewProviders.js'; import { EditorWindowSessionsRuntime } from '../../../browser/editorWindow/editorWindowSessionsRuntime.js'; +import { EditorWindowSessionsPresenterService, IEditorWindowSessionsPresenter } from '../../../browser/editorWindow/editorWindowSessionsPresenter.js'; +import { IChatViewHostService } from '../../../../chat/browser/chatViewHost.js'; +import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { ACTION_ID_NEW_CHAT } from '../../../../chat/browser/actions/chatActions.js'; suite('EditorWindowSessionsRuntime', () => { @@ -86,3 +90,34 @@ suite('EditorWindowSessionsRuntime', () => { ]); }); }); + +suite('EditorWindowSessionsPresenterService', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('without a presenter, a new session starts a new chat in the Chat view, rather than the next request going to the chat it showed', async () => { + const calls: string[] = []; + const service = disposables.add(new EditorWindowSessionsPresenterService( + upcastPartial({ reveal: async preserveFocus => { calls.push(`reveal preserveFocus=${preserveFocus}`); return undefined; } }), + upcastPartial({ executeCommand: async (id: string) => { calls.push(id); return undefined; } }), + )); + + await service.presenter.get().openComposer(undefined, { preserveFocus: true }); + + assert.deepStrictEqual(calls, ['reveal preserveFocus=true', ACTION_ID_NEW_CHAT]); + }); + + test('counts the composers of what holds them, also once it no longer presents the sessions, until it no longer holds them', async () => { + const service = disposables.add(new EditorWindowSessionsPresenterService(upcastPartial({}), upcastPartial({}))); + const closed: string[] = []; + const presenter = service.registerPresenter(upcastPartial({})); + const holder = service.registerComposerHolder({ hasComposer: () => true, closeIdleComposers: async () => { closed.push('chat part'); } }); + + presenter.dispose(); + const afterPresenter = service.hasComposer(); + await service.closeIdleComposers(); + holder.dispose(); + + assert.deepStrictEqual({ afterPresenter, closed, afterHolder: service.hasComposer() }, { afterPresenter: true, closed: ['chat part'], afterHolder: false }); + }); +}); diff --git a/src/vs/workbench/services/sessions/browser/worktreeTrust.ts b/src/vs/workbench/services/sessions/browser/worktreeTrust.ts index e8def3d935dc8a..a5f073ced0bd40 100644 --- a/src/vs/workbench/services/sessions/browser/worktreeTrust.ts +++ b/src/vs/workbench/services/sessions/browser/worktreeTrust.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { isWorktreeUnderRepository } from '../../../../platform/agentHost/common/worktreePaths.js'; -import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; +import { localize } from '../../../../nls.js'; +import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { ISessionWorkspace } from '../common/session.js'; /** @@ -54,3 +55,36 @@ export async function ensureSessionWorktreesTrusted( } })); } + +/** + * Has the user trust every folder that a session of `workspace` works in before it opens, not just the primary one: the + * agent, and its tasks, terminals and other tooling, can run against any of the session's working directories. Worktrees + * that VS Code created off a trusted repository inherit its trust first (see {@link ensureSessionWorktreesTrusted}). + * Shows the workspace-trust dialog for each untrusted folder in turn, and resolves to `false` as soon as the user declines + * one. A workspace that does not require trust, such as a virtual or cloud one, or that has not hydrated yet, needs none. + */ +export async function requestSessionWorkspaceTrust( + workspace: ISessionWorkspace | undefined, + remoteHostLabel: () => string | undefined, + workspaceTrustManagementService: IWorkspaceTrustManagementService, + workspaceTrustRequestService: IWorkspaceTrustRequestService, +): Promise { + if (!workspace?.requiresWorkspaceTrust) { + return true; + } + await ensureSessionWorktreesTrusted(workspace, workspaceTrustManagementService); + const folders = workspace.folders.map(folder => folder.workingDirectory); + const trustInfos = await Promise.all(folders.map(folder => workspaceTrustManagementService.getUriTrustInfo(folder))); + const untrustedFolders = folders.filter((_, index) => !trustInfos[index].trusted); + for (const folder of untrustedFolders) { + const trusted = await workspaceTrustRequestService.requestResourcesTrust({ + uri: folder, + message: localize('sessionsService.trustFolderMessage', "An agent session will be able to read files, run commands, and make changes in this folder."), + remoteHostLabel: remoteHostLabel(), + }); + if (!trusted) { + return false; + } + } + return true; +} From 26ff3037eed12efb6259fd235b17c9b51011cdec Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:37:34 +0100 Subject: [PATCH 031/165] Worktree infra: describe worktrees by branch, compare real paths, and report first sends and restored opens - The location provider resolves through real paths (a symlinked primary or worktree keeps its identity), reads the entry's HEAD for the branch or short commit, and no longer drops all locations when one fails to read. - IChatViewHostShownSession gains reason 'commit' for the chat a first request made; IChatViewHostOpenOptions gains restore to show a session without reporting it as opened. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostWorktreeLocations.ts | 107 ++++++++++++------ .../contrib/chat/browser/chatViewHost.ts | 13 ++- .../viewPane/chatViewHostService.ts | 10 +- .../widgetHosts/viewPane/chatViewPane.ts | 10 +- .../agentHostWorktreeLocations.test.ts | 29 ++++- .../viewPane/chatViewHostService.test.ts | 5 +- .../widgetHosts/viewPane/chatViewPane.test.ts | 9 +- 7 files changed, 134 insertions(+), 49 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts index 3f15cdde56ff82..904f53daca7fa6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts @@ -8,7 +8,7 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js import { Codicon } from '../../../../../../base/common/codicons.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { isAbsolute } from '../../../../../../base/common/path.js'; -import { basename, extUriBiasedIgnorePathCase, joinPath } from '../../../../../../base/common/resources.js'; +import { basename, dirname, extUriBiasedIgnorePathCase, joinPath, normalizePath } from '../../../../../../base/common/resources.js'; import { isDefined } from '../../../../../../base/common/types.js'; import { URI } from '../../../../../../base/common/uri.js'; import { FileOperationResult, IFileService, toFileOperationResult } from '../../../../../../platform/files/common/files.js'; @@ -60,8 +60,8 @@ export class AgentHostWorktreeLocationProvider implements IWorkspaceFolderLocati ) { } async provideLocations(primary: URI, token: CancellationToken): Promise { - const repository = await this._readRepository(primary); - if (!repository || token.isCancellationRequested) { + const worktreesDir = await this._worktreesDir(primary); + if (!worktreesDir || token.isCancellationRequested) { return []; } // Before the sessions list first resolves, it has only the sessions it cached: wait for the ones it has not @@ -74,64 +74,101 @@ export class AgentHostWorktreeLocationProvider implements IWorkspaceFolderLocati } const candidates = getAgentHostSessionWorkingDirectories(model.sessions) .filter(directory => !extUriBiasedIgnorePathCase.isEqual(directory, primary)); - const locations = await Promise.all(candidates.map(candidate => this._resolve(repository, candidate))); + const locations = await Promise.all(candidates.map(async candidate => { + try { + return await this._resolve(worktreesDir, primary, candidate); + } catch (error) { + // One working directory that fails to read, such as a pruned worktree, does not hide the others + this._logService.warn(`[AgentHostWorktreeLocations] Could not read the git checkout of ${candidate.toString()}`, error); + return undefined; + } + })); return token.isCancellationRequested ? [] : locations.filter(isDefined); } async resolveLocation(primary: URI, location: URI, token: CancellationToken): Promise { // Any linked checkout of the repository resolves, also before the sessions load, such as to restore the location // that the window's folder was at - if (extUriBiasedIgnorePathCase.isEqual(location, primary)) { - return undefined; - } - const repository = await this._readRepository(primary); - if (!repository || token.isCancellationRequested) { - return undefined; - } - return this._resolve(repository, location); + const worktreesDir = await this._worktreesDir(primary); + return worktreesDir && !token.isCancellationRequested ? this._resolve(worktreesDir, primary, location) : undefined; } - /** The git repository of the local folder `folder` is a checkout of, if any. */ - private async _readRepository(folder: URI): Promise { - if (folder.scheme !== Schemas.file) { + /** Where the worktrees of the repository of the local folder `primary` are kept: `.git/worktrees` of its real path. */ + private async _worktreesDir(primary: URI): Promise { + if (primary.scheme !== Schemas.file) { return undefined; } try { - return (await readFolderGitCheckout(this._fileService, folder))?.repository; + const checkout = await readFolderGitCheckout(this._fileService, await this._real(primary)); + return checkout && joinPath(await this._real(checkout.repository), '.git', 'worktrees'); } catch (error) { - this._logService.warn(`[AgentHostWorktreeLocations] Could not read the git repository of ${folder.toString()}`, error); + this._logService.warn(`[AgentHostWorktreeLocations] Could not read the git repository of ${primary.toString()}`, error); return undefined; } } - /** `candidate` as a location of `repository`'s folder, when it is a linked checkout of that repository. */ - private async _resolve(repository: URI, candidate: URI): Promise { - if (candidate.scheme !== Schemas.file) { + /** + * `location` as a location of the folder at `primary`, when git links it both ways with an entry of `worktreesDir`: its + * `.git` file names the entry, and the entry's `gitdir` file names its `.git` back, by their real paths, such as of a + * folder reached through a symbolic link. + */ + private async _resolve(worktreesDir: URI, primary: URI, location: URI): Promise { + if (location.scheme !== Schemas.file) { return undefined; } - try { - const checkout = await readFolderGitCheckout(this._fileService, candidate); - if (!checkout?.worktreeGitDir || !extUriBiasedIgnorePathCase.isEqual(checkout.repository, repository) || !await this._namesBack(checkout.worktreeGitDir, candidate)) { - return undefined; - } - } catch (error) { - this._logService.warn(`[AgentHostWorktreeLocations] Could not read the git checkout of ${candidate.toString()}`, error); + const realLocation = await this._real(location); + if (extUriBiasedIgnorePathCase.isEqual(await this._real(primary), realLocation)) { + return undefined; + } + const locationGitFile = joinPath(realLocation, '.git'); + const entry = await this._readLink(locationGitFile, dirname(locationGitFile), /^gitdir:\s*(?.+?)\s*$/m); + if (!entry || !extUriBiasedIgnorePathCase.isEqual(dirname(await this._real(entry)), worktreesDir)) { return undefined; } - return { uri: candidate, label: basename(candidate), icon: Codicon.gitBranchCheckout }; + const backlink = await this._readLink(joinPath(entry, 'gitdir'), entry); + if (!backlink || !extUriBiasedIgnorePathCase.isEqual(await this._real(backlink), locationGitFile)) { + return undefined; + } + // Its branch, from the entry's HEAD: read again each time the location is resolved + const head = await this._readOrUndefined(async () => (await this._fileService.readFile(joinPath(entry, 'HEAD'))).value.toString()); + return { uri: location, label: basename(location), description: head !== undefined ? parseGitHead(head) : undefined, icon: Codicon.gitBranchCheckout }; + } + + /** The path a link file names, absolute and normalized: its content, or the `pattern`'s `path` group of it, relative to `base`. */ + private async _readLink(file: URI, base: URI, pattern?: RegExp): Promise { + const content = await this._readOrUndefined(async () => (await this._fileService.readFile(file)).value.toString()); + const path = content && (pattern ? pattern.exec(content)?.groups?.path : content.trim()); + if (!path) { + return undefined; + } + return normalizePath(isAbsolute(path) ? URI.file(path) : joinPath(base, path)); } - /** Whether the repository's entry for a worktree, `worktreeGitDir`, names the `.git` of `folder` back. */ - private async _namesBack(worktreeGitDir: URI, folder: URI): Promise { - let content: string; + /** `resource` with its symbolic links resolved, or itself where it is not there. */ + private async _real(resource: URI): Promise { + return normalizePath(await this._readOrUndefined(() => this._fileService.realpath(resource)) ?? resource); + } + + /** The result of `read`, or `undefined` where what it reads is not there or is a folder, such as an ordinary checkout's `.git`; any other failure throws. */ + private async _readOrUndefined(read: () => Promise): Promise { try { - content = (await this._fileService.readFile(joinPath(worktreeGitDir, 'gitdir'))).value.toString().trim(); + return await read(); } catch (error) { - if (error instanceof Error && toFileOperationResult(error) === FileOperationResult.FILE_NOT_FOUND) { - return false; + const result = error instanceof Error ? toFileOperationResult(error) : undefined; + if (result === FileOperationResult.FILE_NOT_FOUND || result === FileOperationResult.FILE_IS_DIRECTORY) { + return undefined; } throw error; } - return !!content && extUriBiasedIgnorePathCase.isEqual(isAbsolute(content) ? URI.file(content) : joinPath(worktreeGitDir, content), joinPath(folder, '.git')); } } + +/** The branch a git HEAD names (`ref: refs/heads/`), the short commit of a detached one, or `undefined`. */ +export function parseGitHead(head: string): string | undefined { + const content = head.trim(); + const branch = /^ref:\s*refs\/heads\/(?.+)$/.exec(content)?.groups?.branch; + if (branch) { + return branch; + } + return /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i.test(content) ? content.slice(0, 7) : undefined; +} diff --git a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts index 226d8e995e7d8f..023643c1774412 100644 --- a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts +++ b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts @@ -16,10 +16,12 @@ export const IChatViewHostService = createDecorator('chatV export interface IChatViewHostShownSession { readonly resource: URI; /** - * `open` when the user brought the chat up, such as by picking it in the sessions list, starting a new chat or sending - * the first request of one; `restore` when the host shows the chat it last showed, such as as the window starts. + * `open` when the user brought the chat up, such as by picking it in the sessions list or starting a new chat; + * `commit` when the first request of a new chat made it the session it became, which the user did not pick; `restore` + * when the host shows a chat that it does not bring up for the user, such as the chat it last showed as the window + * starts, or one opened with {@link IChatViewHostOpenOptions.restore}. */ - readonly reason: 'open' | 'restore'; + readonly reason: 'open' | 'commit' | 'restore'; } /** How {@link IChatViewHost.reveal} shows the host. */ @@ -35,6 +37,11 @@ export interface IChatViewHostRevealOptions { export interface IChatViewHostOpenOptions { readonly preserveFocus?: boolean; readonly sessionTypeSelectionReason?: SessionTypeSelectionReason; + /** + * Shows the session as one the host restores ({@link IChatViewHostShownSession.reason} `restore`) rather than one + * the user opens, such as the chat that was last shown where the user moved the window's folder. + */ + readonly restore?: boolean; } /** How {@link IChatViewHost.startSession} starts a session that its caller may send a first request to. */ diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts index fd9906d05b0d13..0769a48767e0d5 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts @@ -53,16 +53,20 @@ export class ChatViewPaneHost implements IChatViewHost { if (view && !options?.preserveFocus) { view.focus(); } - view?.reportOpened(sessionResource); + if (!options?.restore) { + view?.reportOpened(sessionResource); + } return view?.widget; } async openSession(sessionResource: URI, options?: IChatViewHostOpenOptions): Promise { const chatView = await this.viewsService.openView(ChatViewId, !options?.preserveFocus); if (chatView) { - await chatView.loadSession(sessionResource, options?.sessionTypeSelectionReason); + await chatView.loadSession(sessionResource, options?.sessionTypeSelectionReason, options?.restore ? 'restore' : 'open'); // Also a chat it showed already, which loading does not set again - chatView.reportOpened(sessionResource); + if (!options?.restore) { + chatView.reportOpened(sessionResource); + } if (!options?.preserveFocus) { chatView.focusInput(); } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index f906948705e79a..433bd71e7c30f3 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -345,7 +345,8 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } const modelRef = await this.chatService.acquireOrLoadSession(e.committed, ChatAgentLocation.Chat, CancellationToken.None, 'ChatViewPane#onDidCommitSession'); - await this.showModel(CancellationToken.None, modelRef); + // The chat the first request made: a session the user did not pick + await this.showModel(CancellationToken.None, modelRef, undefined, undefined, undefined, undefined, 'commit'); })); // Layout changes @@ -1519,7 +1520,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { this._shownSession.set(shownResource && { resource: shownResource, reason: this._showReason }, undefined); } - /** @param showReason why it shows the chat ({@link shownSession}): only the view's own restore passes `restore`. */ + /** @param showReason why it shows the chat ({@link shownSession}). */ private async showModel(token: CancellationToken, modelRef?: IChatModelReference | undefined, startNewSession = true, ignoreTransferredSession = false, inputBeforeLoad?: string, localFallbackSelectionReason?: SessionTypeSelectionReason, showReason: IChatViewHostShownSession['reason'] = 'open'): Promise { if (token.isCancellationRequested || this._store.isDisposed) { modelRef?.dispose(); @@ -1672,7 +1673,8 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { this.updateActions(); } - async loadSession(sessionResource: URI, sessionTypeSelectionReason?: SessionTypeSelectionReason): Promise { + /** @param showReason why it shows the chat ({@link shownSession}). */ + async loadSession(sessionResource: URI, sessionTypeSelectionReason?: SessionTypeSelectionReason, showReason: 'open' | 'restore' = 'open'): Promise { const t0 = Date.now(); this.logService.trace(`[ChatViewPane] loadSession start uri=${sessionResource.toString()}`); @@ -1730,7 +1732,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } const localFallbackSelectionReason = getLocalFallbackSessionTypeSelectionReason(getChatSessionType(sessionResource), !!newModelRef); - const result = await this.showModel(token, newModelRef, true, false, inputBeforeLoad, localFallbackSelectionReason); + const result = await this.showModel(token, newModelRef, true, false, inputBeforeLoad, localFallbackSelectionReason, showReason); this.logService.trace(`[ChatViewPane] loadSession done total=${Date.now() - t0}ms uri=${sessionResource.toString()}`); return result; } catch (err) { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts index 174b5726638b09..0c1af172077e39 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts @@ -28,7 +28,7 @@ suite('AgentHostWorktreeLocations', () => { * A provider over git entries: `true` is a `.git` directory, and a string the content of a file, such as a worktree's * `.git` file or its entry's `gitdir` file in the repository. */ - function createProvider(entries: Record, sessions: readonly IAgentSession[], uncached: readonly IAgentSession[] = []) { + function createProvider(entries: Record, sessions: readonly IAgentSession[], uncached: readonly IAgentSession[] = [], links: Record = {}) { const fileService = upcastPartial({ stat: async (resource: URI) => { const entry = entries[resource.path]; @@ -37,6 +37,11 @@ suite('AgentHostWorktreeLocations', () => { } return upcastPartial({ isDirectory: entry === true }); }, + // A path through a symbolic link, such as `links['/link/vscode'] = '/work/vscode'`, is the path it links to + realpath: async (resource: URI) => { + const link = Object.keys(links).find(path => resource.path === path || resource.path.startsWith(`${path}/`)); + return link ? URI.file(links[link] + resource.path.slice(link.length)) : resource; + }, readFile: async (resource: URI) => { const entry = entries[resource.path]; if (typeof entry !== 'string') { @@ -59,6 +64,11 @@ suite('AgentHostWorktreeLocations', () => { // A worktree whose repository entry names it back '/work/vscode.worktrees/feature/.git': 'gitdir: /work/vscode/.git/worktrees/feature\n', '/work/vscode/.git/worktrees/feature/gitdir': '/work/vscode.worktrees/feature/.git\n', + '/work/vscode/.git/worktrees/feature/HEAD': 'ref: refs/heads/agents/feature\n', + // A worktree at a detached commit + '/work/vscode.worktrees/detached/.git': 'gitdir: /work/vscode/.git/worktrees/detached\n', + '/work/vscode/.git/worktrees/detached/gitdir': '/work/vscode.worktrees/detached/.git\n', + '/work/vscode/.git/worktrees/detached/HEAD': '0123456789abcdef0123456789abcdef01234567\n', // One with a relative link back '/work/vscode.worktrees/relative/.git': 'gitdir: /work/vscode/.git/worktrees/relative', '/work/vscode/.git/worktrees/relative/gitdir': '../../../../vscode.worktrees/relative/.git', @@ -118,4 +128,21 @@ suite('AgentHostWorktreeLocations', () => { const locations = await provider.provideLocations(URI.file('/work/vscode'), CancellationToken.None); assert.deepStrictEqual(locations.map(location => location.label), ['feature', 'relative']); }); + + test('a worktree is described by its branch, the short commit of a detached HEAD, or nothing without HEAD', async () => { + const provider = createProvider(repository, []); + const describe = async (path: string) => (await provider.resolveLocation(URI.file('/work/vscode'), URI.file(path), CancellationToken.None))?.description; + assert.deepStrictEqual({ branch: await describe('/work/vscode.worktrees/feature'), detached: await describe('/work/vscode.worktrees/detached'), withoutHead: await describe('/work/vscode.worktrees/relative') }, { branch: 'agents/feature', detached: '0123456', withoutHead: undefined }); + }); + + test('compares the folder and its worktrees by their real paths, such as through a symbolic link', async () => { + const provider = createProvider(repository, [session(SessionType.AgentHostCopilot, '/alias/feature')], [], { '/link/vscode': '/work/vscode', '/alias/feature': '/work/vscode.worktrees/feature' }); + const resolve = async (primary: string, location: string) => (await provider.resolveLocation(URI.file(primary), URI.file(location), CancellationToken.None))?.label ?? 'none'; + assert.deepStrictEqual({ + throughLinkedPrimary: await resolve('/link/vscode', '/work/vscode.worktrees/feature'), + linkedWorktree: await resolve('/work/vscode', '/alias/feature'), + primaryItself: await resolve('/link/vscode', '/work/vscode'), + offered: (await provider.provideLocations(URI.file('/link/vscode'), CancellationToken.None)).map(location => location.uri.path), + }, { throughLinkedPrimary: 'feature', linkedWorktree: 'feature', primaryItself: 'none', offered: ['/alias/feature'] }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts index abbfa90a26e95f..60c1a5f36aff1d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts @@ -39,7 +39,7 @@ suite('ChatViewHostService', () => { shownSession, widget: options.widgetCreatedLater ? undefined : widget, get whenWidget() { accesses++; return created.p; }, - loadSession: async (resource: URI, reason?: string) => { calls.push(`loadSession ${resource.toString()} reason=${reason}`); return undefined; }, + loadSession: async (resource: URI, reason?: string, showReason?: string) => { calls.push(`loadSession ${resource.toString()} reason=${reason}${showReason === 'restore' ? ' restore' : ''}`); return undefined; }, startNewLocalSession: async () => { calls.push('startNewLocalSession'); return undefined; }, focus: () => { calls.push('focus'); }, focusInput: () => { calls.push('focusInput'); }, @@ -70,6 +70,8 @@ suite('ChatViewHostService', () => { results.reveal = await service.reveal(false) === widget; results.revealPreservingFocus = await service.reveal(true) === widget; results.openSession = await service.openSession(resource, { sessionTypeSelectionReason: 'explicitOverride' }) === widget; + // One to restore is not reported as opened + await service.openSession(resource, { preserveFocus: true, restore: true }); results.toggle = await service.toggle(); const start = await service.startSession(resource, { local: false, importingConversation: true, beforeOpen: () => calls.push('beforeOpen') }); await start.moved(URI.parse('test:/moved')); @@ -85,6 +87,7 @@ suite('ChatViewHostService', () => { `openView ${ChatViewId} focus=true`, `openView ${ChatViewId} focus=false`, `openView ${ChatViewId} focus=true`, 'loadSession test:/session reason=explicitOverride', 'reportOpened test:/session', 'focusInput', + `openView ${ChatViewId} focus=false`, 'loadSession test:/session reason=undefined restore', `setPartHidden false ${Parts.AUXILIARYBAR_PART}`, `openView ${ChatViewId} focus=undefined`, 'beforeOpen', 'suppress', 'progress', 'loadSession test:/session reason=explicitOverride', 'focus', `openView ${ChatViewId} focus=undefined`, 'loadSession test:/moved reason=undefined', diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index 4e888b7ec9db8c..037e601a335bf2 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -202,7 +202,7 @@ suite('ChatViewPane session transitions', () => { const nextRef = createReference(nextModel); const pane: { showModel(token: CancellationToken, ref: IChatModelReference): Promise; - loadSession(resource: URI): Promise; + loadSession(resource: URI, sessionTypeSelectionReason?: undefined, showReason?: 'open' | 'restore'): Promise; applyModel(): void; restoringSession: Promise | undefined; readonly shownSession: IObservable; @@ -299,6 +299,10 @@ suite('ChatViewPane session transitions', () => { Object.assign(restored.pane, { chatService: { acquireOrLoadSession: async () => savedReference() } }); await restored.pane.loadSession(savedResource); const afterLoad = shown(restored); + // A chat loaded as one to restore, such as the last chat of where the user moved the folder + Object.assign(restored.pane, { chatService: { acquireOrLoadSession: async () => restored.nextRef } }); + await restored.pane.loadSession(nextResource, undefined, 'restore'); + const loadedToRestore = shown(restored); // The user loads a chat while the restore still prepares its chat's configuration const preparing = new DeferredPromise(); @@ -315,11 +319,12 @@ suite('ChatViewPane session transitions', () => { await loading; await restoration; - assert.deepStrictEqual({ afterRestore, otherReported, reopened, afterLoad, superseded: shown(superseded) }, { + assert.deepStrictEqual({ afterRestore, otherReported, reopened, afterLoad, loadedToRestore, superseded: shown(superseded) }, { afterRestore: '/untitled-next restore', otherReported: '/untitled-next restore', reopened: '/untitled-next open', afterLoad: '/saved open', + loadedToRestore: '/untitled-next restore', superseded: '/saved open', }); }); From 6569af4bffbf42bcfabb557fad5e90e15153f3bf Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:40:47 +0100 Subject: [PATCH 032/165] Worktree support: supersede pending moves, follow into enclosing worktrees, and keep the last chat per location in sync - A later open that moves nothing, or disposing, supersedes a move the workspace still makes. - A chat in a folder inside a worktree follows to that worktree. - Recalling the last chat of a location the user moved to opens it as restored, keeping the folder there. - The last chat is remembered also where the folder is already, follows a session a first request replaced, and is forgotten once deleted. - The chat a first request makes follows on its own (ifIdle), so a move the user started wins. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostWorktree.contribution.ts | 125 +++++++++++++-- .../agentHost/agentHostWorktree.test.ts | 150 ++++++++++++++++-- 2 files changed, 251 insertions(+), 24 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts index 10ab350d982664..6b59fccf57c0fc 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts @@ -7,10 +7,10 @@ import { CancellationTokenSource } from '../../../../../../base/common/cancellat import { Codicon } from '../../../../../../base/common/codicons.js'; import { toErrorMessage } from '../../../../../../base/common/errorMessage.js'; import { Disposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; -import { ResourceMap } from '../../../../../../base/common/map.js'; +import { ResourceMap, ResourceSet } from '../../../../../../base/common/map.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { autorun } from '../../../../../../base/common/observable.js'; -import { extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; +import { dirname, extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; import Severity from '../../../../../../base/common/severity.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; @@ -26,14 +26,14 @@ import { Registry } from '../../../../../../platform/registry/common/platform.js import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { logSettingExperimentTrigger } from '../../../../../../platform/telemetry/common/experimentTrigger.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; -import { IWorkspaceFolderLocationService } from '../../../../../../platform/workspace/common/folderLocations.js'; +import { IResolvedFolderLocation, IWorkspaceFolderLocationService } from '../../../../../../platform/workspace/common/folderLocations.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../common/contributions.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { IWorkspaceEditingService } from '../../../../../services/workspaces/common/workspaceEditing.js'; import { ITerminalInstance, ITerminalService } from '../../../../terminal/browser/terminal.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; -import { isLocalAgentHostTarget } from '../../../common/chatSessionsService.js'; +import { IChatSessionsService, isLocalAgentHostTarget } from '../../../common/chatSessionsService.js'; import { ChatConfiguration } from '../../../common/constants.js'; import { isUntitledChatSession, LocalChatSessionUri } from '../../../common/model/chatUri.js'; import { IChatViewHostService } from '../../chatViewHost.js'; @@ -110,12 +110,15 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe private readonly _sessionsListener = this._register(new MutableDisposable()); private readonly _lastSessions: ResourceMap; + /** The remembered sessions the sessions list has listed: one that it no longer lists was deleted. */ + private readonly _listedSessions = new ResourceSet(); /** The locations the folder has been at in this window, whose terminals started there are marked once it moves. */ private readonly _visitedLocations: URI[] = []; constructor( @IChatViewHostService private readonly _chatViewHostService: IChatViewHostService, @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, + @IChatSessionsService chatSessionsService: IChatSessionsService, @IConfigurationService private readonly _configurationService: IConfigurationService, @IContextKeyService private readonly _contextKeyService: IContextKeyService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @@ -142,6 +145,7 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe this._request++; this._foreground++; this._cts.dispose(true); + this._supersedePendingMoves(); this._clearTerminalMarks(); })); @@ -160,7 +164,9 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe this._register(autorun(reader => { const shown = _chatViewHostService.shownSession.read(reader); this._sessionsListener.clear(); - this._shown = shown && !isUntitledChatSession(shown.resource) ? { resource: shown.resource, workingDirectory: undefined, followOnceKnown: shown.reason === 'open' } : undefined; + // A chat the user opens follows; so does one that a first request made, on its own, as the user did not pick it + const follows = shown?.reason === 'open' || shown?.reason === 'commit'; + this._shown = shown && !isUntitledChatSession(shown.resource) ? { resource: shown.resource, workingDirectory: undefined, followOnceKnown: follows } : undefined; if (shown) { this._logTrigger(shown.resource); } @@ -168,9 +174,13 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe // The user's latest choice: no switch asked for before applies, also where this chat moves nothing this._request++; this._foreground++; + this._supersedePendingMoves(); + } else if (shown.reason === 'commit') { + // No automatic switch of a chat shown before applies, but one that the user asked for does + this._foreground++; } if (this._shown?.followOnceKnown) { - this._follow(this._shown, false); + this._follow(this._shown, shown?.reason === 'commit'); } else if (this._shown) { // A restored chat moves nothing, but the folder follows it as it comes to work elsewhere this._shown.workingDirectory = this._workingDirectory(this._shown.resource); @@ -202,6 +212,10 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe } })); this._markTerminals(); + + // A session that a first request made in place of a new chat stays the last one of its location; a deleted one does not + this._register(chatSessionsService.onDidCommitSession(({ original, committed }) => this._replaceLastSession(original, committed))); + this._register(_agentSessionsService.model.onDidChangeSessions(() => this._forgetDeletedSessions())); } /** The session the user last opened while the folder was at `location`, its own location or a worktree. */ @@ -289,10 +303,15 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe this._watchWorkingDirectory(shown); target = shown.workingDirectory; } - if (!target || target.scheme !== Schemas.file || extUriBiasedIgnorePathCase.isEqual(target, folder.uri)) { + if (!target || target.scheme !== Schemas.file || !this._enabled) { return; } - if (!this._enabled || (automatic && this._switching > 0)) { + if (extUriBiasedIgnorePathCase.isEqual(target, folder.uri)) { + // Where the folder is: the chat is the last one of its location also without a move + this._rememberSession(folder.uri, shown.resource); + return; + } + if (automatic && this._switching > 0) { return; } void this._switch(shown.resource, primary, target, automatic ? this._foreground : undefined); @@ -319,7 +338,7 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe if (!extUriBiasedIgnorePathCase.isEqual(target, primary)) { // A worktree, also one inside the folder; else a folder inside the folder's own location works there const exists = await this._exists(target); - const location = exists ? await this._folderLocationService.resolveLocation(primary, target, this._cts.token) : undefined; + const location = exists ? await this._resolveEnclosingLocation(primary, target) : undefined; if (this._store.isDisposed) { return; } @@ -340,6 +359,10 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe } } if (this._isAt(target)) { + // Where the folder is already, such as a chat in a folder inside the worktree it is at + if (request === this._request && this._enabled) { + this._rememberSession(target, session); + } return; } // Read again right before the move: it may have been turned off, disposed, or a later choice came, meanwhile @@ -362,6 +385,10 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe return; } const applied = request === this._request && this._isAt(target); + if (!this._isAt(target) && this._ownMove && extUriBiasedIgnorePathCase.isEqual(this._ownMove, target)) { + // Superseded in the workspace: the next move there is not its own + this._ownMove = undefined; + } if (automatic && applied) { this._rememberSession(target, session); } @@ -377,6 +404,35 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe } } + /** + * The location of `primary` that `target` is in: `target` itself, or the closest folder it is in, such as the worktree + * of a chat that works in a folder inside it. None for a folder in the folder's own location. + */ + private async _resolveEnclosingLocation(primary: URI, target: URI): Promise { + for (let candidate = target; !this._cts.token.isCancellationRequested && !extUriBiasedIgnorePathCase.isEqual(candidate, primary); candidate = dirname(candidate)) { + const location = await this._folderLocationService.resolveLocation(primary, candidate, this._cts.token); + if (location) { + return location; + } + if (extUriBiasedIgnorePathCase.isEqual(dirname(candidate), candidate)) { + break; + } + } + return undefined; + } + + /** + * A later choice keeps the folder where it is, or it is disposed: a move it asked for that the workspace still makes + * does not apply, as asking for where the folder is supersedes it. + */ + private _supersedePendingMoves(): void { + const folder = this._workspaceContextService.getWorkspace().folders[0]; + if (this._pendingMoves === 0 || !folder || !this._folderLocationService.canSetLocation(folder)) { + return; + } + this._workspaceEditingService.setFolderLocation(folder.uri).catch(error => this._logService.error('[worktree] could not keep the folder where it is', error)); + } + private _isAt(location: URI): boolean { const folder = this._workspaceContextService.getWorkspace().folders[0]; return !!folder && extUriBiasedIgnorePathCase.isEqual(folder.uri, location); @@ -396,8 +452,14 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe private _openLastSession(folder: IWorkspaceFolder): void { const last = this.getLastSession(folder.uri); - if (last && !extUriBiasedIgnorePathCase.isEqual(last, this._shown?.resource)) { - this._chatViewHostService.openSession(last, { preserveFocus: true }).catch(error => this._logService.trace('[worktree] could not open the last chat of the folder\'s location', error)); + if (!last) { + return; + } + // The location the user picked again, and its chat, stay the most recent + this._rememberSession(folder.uri, last); + if (!extUriBiasedIgnorePathCase.isEqual(last, this._shown?.resource)) { + // Shown as restored, so that it keeps the folder where the user moved it, also where it works elsewhere now + this._chatViewHostService.openSession(last, { preserveFocus: true, restore: true }).catch(error => this._logService.trace('[worktree] could not open the last chat of the folder\'s location', error)); } } @@ -442,6 +504,10 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe } private _rememberSession(location: URI, session: URI): void { + const latest = [...this._lastSessions].at(-1); + if (latest && extUriBiasedIgnorePathCase.isEqual(latest[0], location) && extUriBiasedIgnorePathCase.isEqual(latest[1], session)) { + return; + } // A chat is the last one of the location it works at only, and the most recent is last, so the oldest goes first for (const [known, resource] of [...this._lastSessions]) { if (extUriBiasedIgnorePathCase.isEqual(resource, session)) { @@ -456,6 +522,43 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe } this._lastSessions.delete(oldest); } + if (this._agentSessionsService.getSession(session)) { + this._listedSessions.add(session); + } + this._storeLastSessions(); + } + + private _replaceLastSession(from: URI, to: URI): void { + let changed = false; + for (const [location, resource] of this._lastSessions) { + if (extUriBiasedIgnorePathCase.isEqual(resource, from)) { + this._lastSessions.set(location, to); + changed = true; + } + } + if (changed) { + this._storeLastSessions(); + } + } + + /** Forgets a remembered session that the sessions list listed but no longer does. */ + private _forgetDeletedSessions(): void { + let changed = false; + for (const [location, resource] of this._lastSessions) { + if (this._agentSessionsService.getSession(resource)) { + this._listedSessions.add(resource); + } else if (this._listedSessions.has(resource)) { + this._listedSessions.delete(resource); + this._lastSessions.delete(location); + changed = true; + } + } + if (changed) { + this._storeLastSessions(); + } + } + + private _storeLastSessions(): void { const entries = [...this._lastSessions].map(([known, resource]) => [known.toString(), resource.toString()]); this._storageService.store(LAST_SESSIONS_STORAGE_KEY, JSON.stringify(entries), StorageScope.WORKSPACE, StorageTarget.MACHINE); } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts index 2815e8bd9f7117..9f6fd5e66f6a65 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts @@ -35,7 +35,7 @@ import { IAgentSession } from '../../../../browser/agentSessions/agentSessionsMo import { IAgentSessionsService } from '../../../../browser/agentSessions/agentSessionsService.js'; import { IChatViewHostService, IChatViewHostShownSession } from '../../../../browser/chatViewHost.js'; import { ChatContextKeys } from '../../../../common/actions/chatContextKeys.js'; -import { SessionType } from '../../../../common/chatSessionsService.js'; +import { IChatSessionCommitEvent, IChatSessionsService, SessionType } from '../../../../common/chatSessionsService.js'; import { ChatConfiguration } from '../../../../common/constants.js'; import { LocalChatSessionUri } from '../../../../common/model/chatUri.js'; @@ -47,7 +47,7 @@ suite('AgentHostWorktree', () => { const created = URI.file('/work/vscode.worktrees/created'); /** The feature in a window whose folder is at `at`, recording the moves it asks for, the chats it opens and its experiment triggers. */ - function createWorktree(options: { readonly enabled: boolean; readonly at?: URI; readonly canSetLocation?: boolean; readonly worktrees?: readonly URI[]; readonly resolving?: Promise; readonly terminalCwd?: URI }) { + function createWorktree(options: { readonly enabled: boolean; readonly at?: URI; readonly canSetLocation?: boolean; readonly worktrees?: readonly URI[]; readonly resolving?: Promise; readonly terminalCwd?: URI; readonly setting?: Promise }) { const worktrees = options.worktrees ?? [worktree, created]; const marks: string[] = []; const terminal = upcastPartial({ @@ -59,8 +59,11 @@ suite('AgentHostWorktree', () => { const onDidChangeSessions = store.add(new Emitter()); const onDidChangeLocation = store.add(new Emitter()); const onDidChangeWorkspaceFolders = store.add(new Emitter()); + const onDidCommitSession = store.add(new Emitter()); const workspace = { at: options.at ?? primary }; const moves: string[] = []; + /** The moves the workspace made: of those it was asked for, each that was still the latest once `options.setting` let it complete. */ + const applied: string[] = []; const opened: string[] = []; const providers: string[] = []; const telemetryService = new TestExperimentTriggerTelemetryService(); @@ -72,8 +75,14 @@ suite('AgentHostWorktree', () => { const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(IChatViewHostService, upcastPartial({ shownSession, - openSession: async (resource: URI) => { opened.push(resource.path); return undefined; }, + // The host reports the chat it opens as shown, as one it restores where asked to + openSession: async (resource: URI, openOptions?: { readonly restore?: boolean }) => { + opened.push(`${resource.path}${openOptions?.restore ? ' (restore)' : ''}`); + shownSession.set({ resource, reason: openOptions?.restore ? 'restore' : 'open' }, undefined); + return undefined; + }, })); + instantiationService.stub(IChatSessionsService, upcastPartial({ onDidCommitSession: onDidCommitSession.event })); instantiationService.stub(IAgentSessionsService, upcastPartial({ getSession: (resource: URI) => { const path = workingDirectories.get(resource.toString()); @@ -98,14 +107,31 @@ suite('AgentHostWorktree', () => { }, registerProvider: () => { providers.push('registered'); return toDisposable(() => providers.push('unregistered')); }, })); - instantiationService.stub(IWorkspaceEditingService, upcastPartial({ - setFolderLocation: async (location: URI, moveOptions?: { readonly ifIdle?: boolean; readonly reason?: string }) => { - moves.push(`${location.path}${moveOptions?.ifIdle ? ' (automatic)' : ''}`); - workspace.at = location; - onDidChangeWorkspaceFolders.fire({ added: [], removed: [], changed: [folder()] }); - onDidChangeLocation.fire({ folder: folder(), reason: moveOptions?.reason === 'user' ? 'user' : 'programmatic' }); - }, - })); + // Like the workspace: only its latest request applies, and an automatic one gives way to one under way + let latestMove = 0; + let movesUnderWay = 0; + const setFolderLocation = async (location: URI, moveOptions?: { readonly ifIdle?: boolean; readonly reason?: string }) => { + if (moveOptions?.ifIdle && movesUnderWay > 0) { + moves.push(`${location.path} (gave way)`); + return; + } + moves.push(`${location.path}${moveOptions?.ifIdle ? ' (automatic)' : ''}`); + const move = ++latestMove; + movesUnderWay++; + try { + await options.setting; + } finally { + movesUnderWay--; + } + if (move !== latestMove) { + return; + } + applied.push(location.path); + workspace.at = location; + onDidChangeWorkspaceFolders.fire({ added: [], removed: [], changed: [folder()] }); + onDidChangeLocation.fire({ folder: folder(), reason: moveOptions?.reason === 'user' ? 'user' : 'programmatic' }); + }; + instantiationService.stub(IWorkspaceEditingService, upcastPartial({ setFolderLocation })); instantiationService.stub(IFileService, upcastPartial({ stat: async () => upcastPartial({}) })); instantiationService.stub(ILabelService, upcastPartial({ getUriLabel: (uri: URI) => uri.path })); instantiationService.stub(INotificationService, new TestNotificationService()); @@ -126,7 +152,10 @@ suite('AgentHostWorktree', () => { onDidChangeWorkspaceFolders.fire({ added: [], removed: [], changed: [folder()] }); onDidChangeLocation.fire({ folder: folder(), reason: 'user' }); }; - return { show, workingDirectories, onDidChangeSessions, moves, opened, providers, triggers: telemetryService.triggers, setEnabled, userMovesTo, contribution, marks }; + /** The user moves the folder in the window's title, which the workspace makes like any other move. */ + const userStartsMoveTo = (location: URI) => setFolderLocation(location, { reason: 'user' }); + const commit = (original: URI, committed: URI) => onDidCommitSession.fire({ original, committed }); + return { show, workingDirectories, onDidChangeSessions, moves, applied, opened, providers, triggers: telemetryService.triggers, setEnabled, userMovesTo, userStartsMoveTo, commit, contribution, marks }; } const settle = () => new Promise(resolve => setTimeout(resolve, 0)); @@ -181,7 +210,7 @@ suite('AgentHostWorktree', () => { assert.deepStrictEqual({ moves: feature.moves, opened: feature.opened }, { moves: ['/work/vscode.worktrees/feature', '/work/vscode'], - opened: ['/a'], + opened: ['/a (restore)'], }); }); @@ -289,4 +318,99 @@ suite('AgentHostWorktree', () => { assert.deepStrictEqual({ off: off.marks, whileOn: whileOn.includes('marked'), afterOff: on.marks.at(-1) }, { off: [], whileOn: true, afterOff: 'unmarked' }); }); + + test('a later choice that moves nothing, or disposing, supersedes a move that the workspace still makes', async () => { + const run = async (supersede: (feature: ReturnType) => void) => { + const setting = new DeferredPromise(); + const feature = createWorktree({ enabled: true, setting: setting.p }); + feature.workingDirectories.set(agentSession('a').toString(), worktree.fsPath); + feature.workingDirectories.set(agentSession('here').toString(), primary.fsPath); + feature.show(agentSession('a')); + await settle(); + supersede(feature); + setting.complete(); + await settle(); + return feature.applied; + }; + assert.deepStrictEqual({ + localChat: await run(feature => feature.show(localChat)), + chatWhereTheFolderIs: await run(feature => feature.show(agentSession('here'))), + newChat: await run(feature => feature.show(agentSession('untitled-1'))), + disposed: await run(feature => feature.contribution.dispose()), + }, { localChat: ['/work/vscode'], chatWhereTheFolderIs: ['/work/vscode'], newChat: ['/work/vscode'], disposed: ['/work/vscode'] }); + }); + + test('a chat in a folder inside a worktree follows to that worktree, and stays at it', async () => { + const nested = URI.file('/work/vscode/.worktrees/w'); + const atFolder = createWorktree({ enabled: true, worktrees: [nested] }); + atFolder.workingDirectories.set(agentSession('src').toString(), URI.file('/work/vscode/.worktrees/w/src').fsPath); + atFolder.show(agentSession('src')); + await settle(); + const atWorktree = createWorktree({ enabled: true, worktrees: [nested], at: nested }); + atWorktree.workingDirectories.set(agentSession('src').toString(), URI.file('/work/vscode/.worktrees/w/src').fsPath); + atWorktree.show(agentSession('src')); + await settle(); + + assert.deepStrictEqual({ atFolder: atFolder.moves, atWorktree: atWorktree.moves }, { atFolder: ['/work/vscode/.worktrees/w'], atWorktree: [] }); + }); + + test('the last chat that shows as the user moves the folder keeps the folder there, also where it works elsewhere now', async () => { + const feature = createWorktree({ enabled: true }); + feature.workingDirectories.set(agentSession('a').toString(), worktree.fsPath); + feature.show(agentSession('a')); + await settle(); + feature.show(localChat); + await settle(); + feature.workingDirectories.set(agentSession('a').toString(), created.fsPath); + feature.userMovesTo(worktree); + await settle(); + + assert.deepStrictEqual({ moves: feature.moves, opened: feature.opened }, { moves: ['/work/vscode.worktrees/feature', '/work/vscode'], opened: ['/a (restore)'] }); + }); + + test('remembers the last chat opened where the folder is already, follows a session that replaces it, and forgets a deleted one', async () => { + const feature = createWorktree({ enabled: true }); + feature.workingDirectories.set(agentSession('a').toString(), worktree.fsPath); + feature.workingDirectories.set(agentSession('b').toString(), worktree.fsPath); + feature.show(agentSession('a')); + await settle(); + feature.show(agentSession('b')); + await settle(); + feature.show(localChat); + await settle(); + const atPrimary = feature.contribution.getLastSession(primary)?.path; + const atWorktree = feature.contribution.getLastSession(worktree)?.path; + feature.commit(agentSession('b'), agentSession('c')); + const replaced = feature.contribution.getLastSession(worktree)?.path; + feature.workingDirectories.set(agentSession('c').toString(), worktree.fsPath); + feature.onDidChangeSessions.fire(); + feature.workingDirectories.delete(agentSession('c').toString()); + feature.onDidChangeSessions.fire(); + + assert.deepStrictEqual({ atPrimary, atWorktree, replaced, deleted: feature.contribution.getLastSession(worktree) }, { + atPrimary: localChat.path, atWorktree: '/b', replaced: '/c', deleted: undefined, + }); + }); + + test('the chat that a first request makes follows on its own, so a move that the user started still wins', async () => { + const plain = createWorktree({ enabled: true }); + plain.workingDirectories.set(agentSession('c').toString(), created.fsPath); + plain.show(agentSession('c'), 'commit'); + await settle(); + + const setting = new DeferredPromise(); + const held = createWorktree({ enabled: true, setting: setting.p }); + held.workingDirectories.set(agentSession('c').toString(), created.fsPath); + const userMove = held.userStartsMoveTo(worktree); + held.show(agentSession('c'), 'commit'); + await settle(); + setting.complete(); + await userMove; + await settle(); + + assert.deepStrictEqual({ plain: plain.moves, held: { moves: held.moves, applied: held.applied } }, { + plain: ['/work/vscode.worktrees/created (automatic)'], + held: { moves: ['/work/vscode.worktrees/feature', '/work/vscode.worktrees/created (gave way)'], applied: ['/work/vscode.worktrees/feature'] }, + }); + }); }); From 7e256b014635dd6ddc44e2bb69b1e93749c2f718 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:42:02 +0100 Subject: [PATCH 033/165] Editor window sessions: one registration per call, and a test of New Session without a presenter - IEditorWindowSessionsRuntime.enable, registerPresenter and registerComposerHolder keep one registration per call, also of the same options, presenter or holder: disposing one removes only it, as ChatViewHostService.registerHost does. - Tests: the same options enabled twice stay enabled until both are disposed; the same presenter registered twice with another between; New Session through EditorWindowSessionsService starts a new chat in the Chat view without a presenter, and a composer aimed at its target (folder, model, Dev Container, side) with one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../editorWindowSessionsPresenter.ts | 21 ++--- .../editorWindowSessionsRuntime.ts | 12 +-- .../editorWindowSessionsRuntime.test.ts | 80 ++++++++++++++++++- 3 files changed, 98 insertions(+), 15 deletions(-) diff --git a/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsPresenter.ts b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsPresenter.ts index cd5aab53032819..e662e5ef0f3616 100644 --- a/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsPresenter.ts +++ b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsPresenter.ts @@ -122,9 +122,10 @@ export class EditorWindowSessionsPresenterService extends Disposable implements declare readonly _serviceBrand: undefined; - private readonly _registered = observableValue(this, []); + /** One per registration, also of the same presenter or holder: each is removed by its own disposal only. */ + private readonly _registered = observableValue(this, []); readonly presenter: IObservable; - private readonly _composerHolders = new Set(); + private readonly _composerHolders = new Set<{ readonly holder: IEditorWindowComposerHolder }>(); constructor( @IChatViewHostService chatViewHostService: IChatViewHostService, @@ -132,25 +133,27 @@ export class EditorWindowSessionsPresenterService extends Disposable implements ) { super(); const chatView = new ChatViewSessionsPresenter(chatViewHostService, commandService); - this.presenter = derived(this, reader => this._registered.read(reader).at(-1) ?? chatView); + this.presenter = derived(this, reader => this._registered.read(reader).at(-1)?.presenter ?? chatView); } registerPresenter(presenter: IEditorWindowSessionsPresenter): IDisposable { - this._registered.set([...this._registered.get(), presenter], undefined); - return toDisposable(() => this._registered.set(this._registered.get().filter(candidate => candidate !== presenter), undefined)); + const registration = { presenter }; + this._registered.set([...this._registered.get(), registration], undefined); + return toDisposable(() => this._registered.set(this._registered.get().filter(candidate => candidate !== registration), undefined)); } registerComposerHolder(holder: IEditorWindowComposerHolder): IDisposable { - this._composerHolders.add(holder); - return toDisposable(() => this._composerHolders.delete(holder)); + const registration = { holder }; + this._composerHolders.add(registration); + return toDisposable(() => this._composerHolders.delete(registration)); } hasComposer(): boolean { - return [...this._composerHolders].some(holder => holder.hasComposer()); + return [...this._composerHolders].some(({ holder }) => holder.hasComposer()); } async closeIdleComposers(): Promise { - await Promise.all([...this._composerHolders].map(holder => holder.closeIdleComposers())); + await Promise.all([...this._composerHolders].map(({ holder }) => holder.closeIdleComposers())); } } diff --git a/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsRuntime.ts b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsRuntime.ts index 8508b002e8f0e5..265d8ae29d269d 100644 --- a/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsRuntime.ts +++ b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsRuntime.ts @@ -40,7 +40,8 @@ export class EditorWindowSessionsRuntime extends Disposable implements IEditorWi declare readonly _serviceBrand: undefined; - private readonly _owners = observableValue(this, []); + /** One per call of {@link enable}, also of the same options: each is removed by its own disposal only. */ + private readonly _owners = observableValue(this, []); readonly enabled: IObservable = derived(this, reader => this._owners.read(reader).length > 0); private readonly _hold = this._register(new MutableDisposable()); @@ -57,11 +58,12 @@ export class EditorWindowSessionsRuntime extends Disposable implements IEditorWi } enable(options: IEditorWindowSessionsRuntimeOptions): IDisposable { - this._owners.set([...this._owners.get(), options], undefined); + const owner = { options }; + this._owners.set([...this._owners.get(), owner], undefined); this._listen(); this._update(); return toDisposable(() => { - this._owners.set(this._owners.get().filter(candidate => candidate !== options), undefined); + this._owners.set(this._owners.get().filter(candidate => candidate !== owner), undefined); if (!this._owners.get().length) { this._listeners.clear(); } @@ -75,7 +77,7 @@ export class EditorWindowSessionsRuntime extends Disposable implements IEditorWi } const listeners = this._listeners.value = new DisposableStore(); listeners.add(this._viewsService.onDidChangeViewVisibility(e => { - if (this._owners.get().some(owner => owner.viewIds.includes(e.id))) { + if (this._owners.get().some(owner => owner.options.viewIds.includes(e.id))) { this._update(); } })); @@ -97,7 +99,7 @@ export class EditorWindowSessionsRuntime extends Disposable implements IEditorWi return; } const owners = this._owners.get(); - const shown = owners.length > 0 && (this._layoutService.isVisible(Parts.CHAT_PART) || owners.some(owner => owner.viewIds.some(id => this._viewsService.isViewVisible(id)))); + const shown = owners.length > 0 && (this._layoutService.isVisible(Parts.CHAT_PART) || owners.some(owner => owner.options.viewIds.some(id => this._viewsService.isViewVisible(id)))); if (shown) { this._hold.value ??= this._providers.acquire(); } else { diff --git a/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts b/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts index 1a371b0a65c3af..fe91e56907c4ea 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts @@ -13,10 +13,17 @@ import { ILifecycleService } from '../../../../../services/lifecycle/common/life import { IViewsService } from '../../../../../services/views/common/viewsService.js'; import { IAgentsViewProvidersService } from '../../../browser/agentsView/agentsViewProviders.js'; import { EditorWindowSessionsRuntime } from '../../../browser/editorWindow/editorWindowSessionsRuntime.js'; -import { EditorWindowSessionsPresenterService, IEditorWindowSessionsPresenter } from '../../../browser/editorWindow/editorWindowSessionsPresenter.js'; +import { EditorWindowSessionsPresenterService, IEditorWindowSessionsPresenter, IEditorWindowSessionsPresenterService } from '../../../browser/editorWindow/editorWindowSessionsPresenter.js'; import { IChatViewHostService } from '../../../../chat/browser/chatViewHost.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { ACTION_ID_NEW_CHAT } from '../../../../chat/browser/actions/chatActions.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { IEditorService } from '../../../../../services/editor/common/editorService.js'; +import { ISessionHost } from '../../../../../services/sessions/browser/sessionHost.js'; +import { EditorWindowSessionsService } from '../../../browser/agentsView/editorWindowSessionsService.js'; +import { ISessionsManagementService } from '../../../common/sessionsManagement.js'; suite('EditorWindowSessionsRuntime', () => { @@ -61,6 +68,23 @@ suite('EditorWindowSessionsRuntime', () => { assert.deepStrictEqual({ events, enabled: runtime.enabled.get() }, { events: [], enabled: false }); }); + test('a feature that enables it twice with the same options keeps it enabled until it disposes both', async () => { + const events: string[] = []; + const { runtime } = createRuntime({ views: new Set(['list']), chatPart: false }, events); + const options = { viewIds: ['list'] }; + + const first = runtime.enable(options); + const second = runtime.enable(options); + first.dispose(); + const afterFirst = { enabled: runtime.enabled.get(), events: [...events] }; + second.dispose(); + + assert.deepStrictEqual({ afterFirst, afterBoth: { enabled: runtime.enabled.get(), events } }, { + afterFirst: { enabled: true, events: ['listen views', 'listen parts', 'acquire'] }, + afterBoth: { enabled: false, events: ['listen views', 'listen parts', 'acquire', 'unlisten views', 'unlisten parts', 'release'] }, + }); + }); + test('holds the providers while a view of a feature that enabled it, or the chat part, shows, as the window restores too', async () => { const events: string[] = []; const { runtime, showView, showChatPart } = createRuntime({ views: new Set(), chatPart: true }, events); @@ -107,6 +131,28 @@ suite('EditorWindowSessionsPresenterService', () => { assert.deepStrictEqual(calls, ['reveal preserveFocus=true', ACTION_ID_NEW_CHAT]); }); + test('the presenter registered last presents, until its registration is disposed, also when the same presenter is registered twice', () => { + const service = disposables.add(new EditorWindowSessionsPresenterService(upcastPartial({}), upcastPartial({}))); + const chatView = service.presenter.get(); + const first = upcastPartial({}); + const second = upcastPartial({}); + const name = () => service.presenter.get() === first ? 'first' : service.presenter.get() === second ? 'second' : service.presenter.get() === chatView ? 'chat view' : 'other'; + const steps: string[] = []; + + const firstEarlier = service.registerPresenter(first); + const secondRegistration = service.registerPresenter(second); + const firstAgain = service.registerPresenter(first); + steps.push(name()); + firstEarlier.dispose(); + steps.push(name()); + firstAgain.dispose(); + steps.push(name()); + secondRegistration.dispose(); + steps.push(name()); + + assert.deepStrictEqual(steps, ['first', 'first', 'second', 'chat view']); + }); + test('counts the composers of what holds them, also once it no longer presents the sessions, until it no longer holds them', async () => { const service = disposables.add(new EditorWindowSessionsPresenterService(upcastPartial({}), upcastPartial({}))); const closed: string[] = []; @@ -121,3 +167,35 @@ suite('EditorWindowSessionsPresenterService', () => { assert.deepStrictEqual({ afterPresenter, closed, afterHolder: service.hasComposer() }, { afterPresenter: true, closed: ['chat part'], afterHolder: false }); }); }); + +suite('EditorWindowSessionsService', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('New Session starts a new chat in the Chat view while no feature presents the sessions, and a composer aimed at its target while one does', async () => { + const calls: string[] = []; + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IChatViewHostService, upcastPartial({ reveal: async () => { calls.push('reveal'); return undefined; } })); + instantiationService.stub(ICommandService, upcastPartial({ executeCommand: async (id: string) => { calls.push(id); return undefined; } })); + const presenterService = disposables.add(instantiationService.createInstance(EditorWindowSessionsPresenterService)); + instantiationService.stub(IEditorWindowSessionsPresenterService, presenterService); + instantiationService.stub(ISessionHost, upcastPartial({})); + instantiationService.stub(ISessionsManagementService, upcastPartial({})); + instantiationService.stub(IWorkspaceContextService, upcastPartial({ getWorkspace: () => upcastPartial>({ folders: [] }), getWorkspaceFolder: () => null })); + instantiationService.stub(IEditorService, upcastPartial({ activeEditor: undefined })); + const service = disposables.add(instantiationService.createInstance(EditorWindowSessionsService)); + + await service.openNewSession(); + const withoutPresenter = calls.splice(0); + const registration = presenterService.registerPresenter(upcastPartial({ + openComposer: async (target, options) => { calls.push(`composer ${target?.folderUri?.path} model=${target?.modelId} devContainer=${target?.requireDevContainer} toSide=${options?.sideBySide}`); }, + })); + await service.openNewSession({ folderUri: URI.file('/repo'), modelId: 'model', requireDevContainer: true, toSide: true }); + registration.dispose(); + + assert.deepStrictEqual({ withoutPresenter, withPresenter: calls }, { + withoutPresenter: ['reveal', ACTION_ID_NEW_CHAT], + withPresenter: ['composer /repo model=model devContainer=true toSide=true'], + }); + }); +}); From e5f464abae4801c643f47c38eef499f9e5ddfb0f Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:42:22 +0100 Subject: [PATCH 034/165] Chat part: report an explicit open as opened, hold composers apart from presenting, and stop a deactivated start early - An explicit open of the chat part's active tab, also one it restored, reports it as opened (as the Chat view's reportOpened does). - The chat part holds the providers for its composers for as long as it lives, as a composer holder, apart from presenting the window's sessions while chats open in it. - AgentsChatViewHost.startSession captures its generation before it loads the model, and opens no fallback composer once deactivated while it looked the session up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentsView/agentsChatPart.ts | 4 ++++ .../agentsView/agentsChatPartContribution.ts | 10 +++++++--- .../browser/agentsView/agentsChatViewHost.ts | 7 ++++++- .../browser/agentsView/agentsChatPart.test.ts | 6 ++++-- .../agentsView/agentsChatViewHost.test.ts | 18 ++++++++++++++++++ .../editorWindowChatPartService.test.ts | 17 ++++++++++------- 6 files changed, 49 insertions(+), 13 deletions(-) diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts index 86ce946b2eb043..94506e58dc2bf9 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts @@ -475,6 +475,10 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart ?? (options?.sideBySide && !part.activeGroup.isEmpty ? part.addGroup(part.activeGroup, GroupDirection.RIGHT) : part.activeGroup); await this._editorService.openEditor(chat.input, { pinned: true, preserveFocus: options?.preserveFocus }, group); this.composers.didOpen([chat.chat.get()]); + // Opened, also a tab it restored and shows already, such as one the user picks in a sessions list + if (this._activeInput.get() === chat.input) { + this._activeReason.set('open', undefined); + } } private _findChat(resource: URI): AgentsChatPartChat | undefined { diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContribution.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContribution.ts index 49b31fe8e11fbc..162d993db9b4d6 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContribution.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContribution.ts @@ -128,8 +128,14 @@ export class EditorWindowChatPartService extends Disposable implements IEditorWi })); store.add(toDisposable(() => visible.reset())); - // The sessions of its chats are known while it shows + // The sessions of its chats are known while it shows, and while its composers hold drafts, also while chats no longer + // open in it store.add(this._sessionsRuntime.enable({ viewIds: [] })); + const existing = () => this._frame.contentCreated.get() ? this._chatPart() : undefined; + store.add(this._presenterService.registerComposerHolder({ + hasComposer: () => !!existing()?.hasComposer(), + closeIdleComposers: async () => existing()?.closeIdleComposers(), + })); // While chats open in it, it replaces the Chat view, and shows the chats of the window's sessions. The host and the // chat part's content are created as the first request comes, not as the window starts. @@ -182,8 +188,6 @@ export class EditorWindowChatPartService extends Disposable implements IEditorWi openComposer: (target, options) => this._chatPart().openComposer(target, options), closeChat: async chatResource => existing()?.closeChat(chatResource), isShowing: chatResource => !!existing()?.isShowing(chatResource), - hasComposer: () => !!existing()?.hasComposer(), - closeIdleComposers: async () => existing()?.closeIdleComposers(), focus: () => this._chatPart().focus(), }; } diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts index 8e0ec842095f4e..45e768ed8fda0e 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts @@ -150,6 +150,8 @@ export class AgentsChatViewHost implements IChatViewHost { * shows instead, where a request that did not run can be put back. */ async startSession(sessionResource: URI, options: IChatViewHostSessionStartOptions): Promise { + // Deactivated from here on, such as while its model loads, it shows nothing it started + const generation = this._generation; if (options.local) { // It shows sessions, not local chat this._notificationService.info(localize('agentsChatViewHost.localChatUnavailable', "Local chat sessions are not available while chats open in the chat part.")); @@ -160,7 +162,6 @@ export class AgentsChatViewHost implements IChatViewHost { throw new Error(`Unable to load the chat session ${sessionResource.toString()}`); } const sessionType = sessionResource.scheme; - const generation = this._generation; let composer: Promise | undefined; return { moved: async () => { }, @@ -171,6 +172,10 @@ export class AgentsChatViewHost implements IChatViewHost { } const given = !isUntitledChatSession(sessionResource); const shown = (sent || given || !isEqual(resource, sessionResource)) && !!await this.openSession(resource); + if (!shown && generation !== this._generation) { + // Deactivated while it looked the session up: no composer either + return false; + } if (!shown) { // Shown before the start completes, and given back by createComposer composer = this._openComposer(sessionType); diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts index a32dc9ad01c464..1d5d20fa3462e7 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts @@ -548,7 +548,7 @@ suite('AgentsChatPartService', () => { }); }); - test('the chat of the active tab shows as restored while the chat part restored it, and as opened once the user opens a chat', async () => { + test('the chat of the active tab shows as restored while the chat part restored it, and as opened once the user opens a chat, also the restored one', async () => { const shownSession = buildTestSession({ id: 'shown', title: 'Shown', chats: [{ id: 'peer', title: 'Peer' }] }); const main = shownSession.mainChat.chat.resource; const peer = shownSession.chats.get('peer')!.chat.resource; @@ -561,9 +561,11 @@ suite('AgentsChatPartService', () => { await service.restore(); const restored = await shown(); + await service.openChat(shownSession.session, peer); + const reopened = await shown(); await service.openChat(shownSession.session, main); - assert.deepStrictEqual({ restored, opened: await shown() }, { restored: 'peer restore', opened: 'main open' }); + assert.deepStrictEqual({ restored, reopened, opened: await shown() }, { restored: 'peer restore', reopened: 'peer open', opened: 'main open' }); }); test('its composers offer the window\'s folders, or those a feature provides while it does', () => { diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts index b6ee82de2842eb..65eded26605b95 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts @@ -38,6 +38,7 @@ suite('AgentsChatViewHost', () => { readonly widgets?: IChatWidget[]; readonly providersAcquired?: () => void; readonly contentCreated?: IObservable; + readonly chatService?: Partial; } function createHost(options: IHostOptions = {}) { @@ -72,6 +73,7 @@ suite('AgentsChatViewHost', () => { calls.push(`hold ${resource.toString()}`); return upcastPartial({ dispose: () => calls.push(`release ${resource.toString()}`) }); }, + ...options.chatService, }), upcastPartial({ info: message => { calls.push(`info ${message}`); return undefined!; } }), upcastPartial({ contentCreated: options.contentCreated ?? constObservable(true) }), @@ -199,6 +201,22 @@ suite('AgentsChatViewHost', () => { }); }); + test('deactivated while it loads the session it starts, it shows neither the session nor a composer', async () => { + const load = new DeferredPromise(); + const calls: string[] = []; + const { host, calls: hostCalls } = createHost({ chatService: { acquireOrLoadSession: async () => { await load.p; return upcastPartial({ dispose: () => calls.push('release model') }); } } }); + + const starting = host.startSession(URI.parse('agent-host-copilot:/untitled-1'), { local: false, importingConversation: false }); + host.deactivate(); + load.complete(); + const start = await starting; + const shown = await start.complete(URI.parse('agent-host-copilot:/session-1'), true); + const composer = await start.createComposer(); + start.dispose(); + + assert.deepStrictEqual({ shown, composer, calls: [...hostCalls, ...calls] }, { shown: false, composer: undefined, calls: ['release model'] }); + }); + test('deactivated while it looks a session up, it does not open the chat part', async () => { const chatResource = URI.parse('agent-host-copilot:/session-1'); const { owner, widget } = sessionOf(chatResource); diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/editorWindowChatPartService.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/editorWindowChatPartService.test.ts index 5fc9408eb37fc0..8fa7881640db9f 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/editorWindowChatPartService.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/editorWindowChatPartService.test.ts @@ -57,7 +57,10 @@ suite('EditorWindowChatPartService', () => { contextKeyService, upcastPartial({ when: () => Promise.resolve() }), upcastPartial({ registerHost: () => { events.push('register host'); return toDisposable(() => events.push('unregister host')); } }), - upcastPartial({ registerPresenter: () => { events.push('register presenter'); return toDisposable(() => events.push('unregister presenter')); } }), + upcastPartial({ + registerPresenter: () => { events.push('register presenter'); return toDisposable(() => events.push('unregister presenter')); }, + registerComposerHolder: () => { events.push('register composer holder'); return toDisposable(() => events.push('unregister composer holder')); }, + }), upcastPartial({ enable: () => { events.push('hold providers'); return toDisposable(() => events.push('release providers')); } }), upcastPartial({ get part() { events.push('create part'); return undefined!; }, contentCreated: constObservable(false) }), )); @@ -80,17 +83,17 @@ suite('EditorWindowChatPartService', () => { assert.deepStrictEqual(events, [ '-- nothing enabled', - 'create part', 'hold providers', 'register host', 'register presenter', 'anchor list', + 'create part', 'hold providers', 'register composer holder', 'register host', 'register presenter', 'anchor list', '-- the list enables it', 'anchor view', '-- the view enables it', 'anchor list', '-- the view no longer does', - 'unregister host', 'deactivate host', 'unregister presenter', 'anchor undefined', 'release providers', + 'unregister host', 'deactivate host', 'unregister presenter', 'anchor undefined', 'release providers', 'unregister composer holder', ]); }); - test('while chats do not open in it, it neither restores nor shows, and the Chat view keeps its chats; once they do, it replaces the Chat view', async () => { + test('while chats do not open in it, it neither restores nor shows, and the Chat view keeps its chats; once they do, it replaces the Chat view; its composers hold the providers throughout', async () => { const enabled = observableValue('enabled', false); const { service, events } = createService({ visible: true }); const step = (label: string) => events.push(`-- ${label}`); @@ -105,13 +108,13 @@ suite('EditorWindowChatPartService', () => { registration.dispose(); assert.deepStrictEqual(events, [ - 'create part', 'hold providers', 'anchor chat', + 'create part', 'hold providers', 'register composer holder', 'anchor chat', 'hide', '-- chats open in the chat part', 'register host', 'register presenter', '-- they no longer do', 'unregister host', 'deactivate host', 'unregister presenter', - 'anchor undefined', 'release providers', + 'anchor undefined', 'release providers', 'unregister composer holder', ]); }); @@ -121,6 +124,6 @@ suite('EditorWindowChatPartService', () => { disposables.add(service.enable({ anchorViewId: 'list', anchorPriority: 1, enabled: constObservable(true) })); await timeout(0); - assert.deepStrictEqual(events, ['create part', 'hold providers', 'register host', 'register presenter', 'anchor list', 'restore']); + assert.deepStrictEqual(events, ['create part', 'hold providers', 'register composer holder', 'register host', 'register presenter', 'anchor list', 'restore']); }); }); From 1272926d0948ffa1704fba9831b7c81831de6633 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:37:34 +0100 Subject: [PATCH 035/165] Worktree infra: describe worktrees by branch, compare real paths, and report first sends and restored opens - The location provider resolves through real paths (a symlinked primary or worktree keeps its identity), reads the entry's HEAD for the branch or short commit, and no longer drops all locations when one fails to read. - IChatViewHostShownSession gains reason 'commit' for the chat a first request made; IChatViewHostOpenOptions gains restore to show a session without reporting it as opened. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostWorktreeLocations.ts | 107 ++++++++++++------ .../contrib/chat/browser/chatViewHost.ts | 13 ++- .../viewPane/chatViewHostService.ts | 10 +- .../widgetHosts/viewPane/chatViewPane.ts | 10 +- .../agentHostWorktreeLocations.test.ts | 29 ++++- .../viewPane/chatViewHostService.test.ts | 5 +- .../widgetHosts/viewPane/chatViewPane.test.ts | 10 +- 7 files changed, 135 insertions(+), 49 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts index 3f15cdde56ff82..904f53daca7fa6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts @@ -8,7 +8,7 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js import { Codicon } from '../../../../../../base/common/codicons.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { isAbsolute } from '../../../../../../base/common/path.js'; -import { basename, extUriBiasedIgnorePathCase, joinPath } from '../../../../../../base/common/resources.js'; +import { basename, dirname, extUriBiasedIgnorePathCase, joinPath, normalizePath } from '../../../../../../base/common/resources.js'; import { isDefined } from '../../../../../../base/common/types.js'; import { URI } from '../../../../../../base/common/uri.js'; import { FileOperationResult, IFileService, toFileOperationResult } from '../../../../../../platform/files/common/files.js'; @@ -60,8 +60,8 @@ export class AgentHostWorktreeLocationProvider implements IWorkspaceFolderLocati ) { } async provideLocations(primary: URI, token: CancellationToken): Promise { - const repository = await this._readRepository(primary); - if (!repository || token.isCancellationRequested) { + const worktreesDir = await this._worktreesDir(primary); + if (!worktreesDir || token.isCancellationRequested) { return []; } // Before the sessions list first resolves, it has only the sessions it cached: wait for the ones it has not @@ -74,64 +74,101 @@ export class AgentHostWorktreeLocationProvider implements IWorkspaceFolderLocati } const candidates = getAgentHostSessionWorkingDirectories(model.sessions) .filter(directory => !extUriBiasedIgnorePathCase.isEqual(directory, primary)); - const locations = await Promise.all(candidates.map(candidate => this._resolve(repository, candidate))); + const locations = await Promise.all(candidates.map(async candidate => { + try { + return await this._resolve(worktreesDir, primary, candidate); + } catch (error) { + // One working directory that fails to read, such as a pruned worktree, does not hide the others + this._logService.warn(`[AgentHostWorktreeLocations] Could not read the git checkout of ${candidate.toString()}`, error); + return undefined; + } + })); return token.isCancellationRequested ? [] : locations.filter(isDefined); } async resolveLocation(primary: URI, location: URI, token: CancellationToken): Promise { // Any linked checkout of the repository resolves, also before the sessions load, such as to restore the location // that the window's folder was at - if (extUriBiasedIgnorePathCase.isEqual(location, primary)) { - return undefined; - } - const repository = await this._readRepository(primary); - if (!repository || token.isCancellationRequested) { - return undefined; - } - return this._resolve(repository, location); + const worktreesDir = await this._worktreesDir(primary); + return worktreesDir && !token.isCancellationRequested ? this._resolve(worktreesDir, primary, location) : undefined; } - /** The git repository of the local folder `folder` is a checkout of, if any. */ - private async _readRepository(folder: URI): Promise { - if (folder.scheme !== Schemas.file) { + /** Where the worktrees of the repository of the local folder `primary` are kept: `.git/worktrees` of its real path. */ + private async _worktreesDir(primary: URI): Promise { + if (primary.scheme !== Schemas.file) { return undefined; } try { - return (await readFolderGitCheckout(this._fileService, folder))?.repository; + const checkout = await readFolderGitCheckout(this._fileService, await this._real(primary)); + return checkout && joinPath(await this._real(checkout.repository), '.git', 'worktrees'); } catch (error) { - this._logService.warn(`[AgentHostWorktreeLocations] Could not read the git repository of ${folder.toString()}`, error); + this._logService.warn(`[AgentHostWorktreeLocations] Could not read the git repository of ${primary.toString()}`, error); return undefined; } } - /** `candidate` as a location of `repository`'s folder, when it is a linked checkout of that repository. */ - private async _resolve(repository: URI, candidate: URI): Promise { - if (candidate.scheme !== Schemas.file) { + /** + * `location` as a location of the folder at `primary`, when git links it both ways with an entry of `worktreesDir`: its + * `.git` file names the entry, and the entry's `gitdir` file names its `.git` back, by their real paths, such as of a + * folder reached through a symbolic link. + */ + private async _resolve(worktreesDir: URI, primary: URI, location: URI): Promise { + if (location.scheme !== Schemas.file) { return undefined; } - try { - const checkout = await readFolderGitCheckout(this._fileService, candidate); - if (!checkout?.worktreeGitDir || !extUriBiasedIgnorePathCase.isEqual(checkout.repository, repository) || !await this._namesBack(checkout.worktreeGitDir, candidate)) { - return undefined; - } - } catch (error) { - this._logService.warn(`[AgentHostWorktreeLocations] Could not read the git checkout of ${candidate.toString()}`, error); + const realLocation = await this._real(location); + if (extUriBiasedIgnorePathCase.isEqual(await this._real(primary), realLocation)) { + return undefined; + } + const locationGitFile = joinPath(realLocation, '.git'); + const entry = await this._readLink(locationGitFile, dirname(locationGitFile), /^gitdir:\s*(?.+?)\s*$/m); + if (!entry || !extUriBiasedIgnorePathCase.isEqual(dirname(await this._real(entry)), worktreesDir)) { return undefined; } - return { uri: candidate, label: basename(candidate), icon: Codicon.gitBranchCheckout }; + const backlink = await this._readLink(joinPath(entry, 'gitdir'), entry); + if (!backlink || !extUriBiasedIgnorePathCase.isEqual(await this._real(backlink), locationGitFile)) { + return undefined; + } + // Its branch, from the entry's HEAD: read again each time the location is resolved + const head = await this._readOrUndefined(async () => (await this._fileService.readFile(joinPath(entry, 'HEAD'))).value.toString()); + return { uri: location, label: basename(location), description: head !== undefined ? parseGitHead(head) : undefined, icon: Codicon.gitBranchCheckout }; + } + + /** The path a link file names, absolute and normalized: its content, or the `pattern`'s `path` group of it, relative to `base`. */ + private async _readLink(file: URI, base: URI, pattern?: RegExp): Promise { + const content = await this._readOrUndefined(async () => (await this._fileService.readFile(file)).value.toString()); + const path = content && (pattern ? pattern.exec(content)?.groups?.path : content.trim()); + if (!path) { + return undefined; + } + return normalizePath(isAbsolute(path) ? URI.file(path) : joinPath(base, path)); } - /** Whether the repository's entry for a worktree, `worktreeGitDir`, names the `.git` of `folder` back. */ - private async _namesBack(worktreeGitDir: URI, folder: URI): Promise { - let content: string; + /** `resource` with its symbolic links resolved, or itself where it is not there. */ + private async _real(resource: URI): Promise { + return normalizePath(await this._readOrUndefined(() => this._fileService.realpath(resource)) ?? resource); + } + + /** The result of `read`, or `undefined` where what it reads is not there or is a folder, such as an ordinary checkout's `.git`; any other failure throws. */ + private async _readOrUndefined(read: () => Promise): Promise { try { - content = (await this._fileService.readFile(joinPath(worktreeGitDir, 'gitdir'))).value.toString().trim(); + return await read(); } catch (error) { - if (error instanceof Error && toFileOperationResult(error) === FileOperationResult.FILE_NOT_FOUND) { - return false; + const result = error instanceof Error ? toFileOperationResult(error) : undefined; + if (result === FileOperationResult.FILE_NOT_FOUND || result === FileOperationResult.FILE_IS_DIRECTORY) { + return undefined; } throw error; } - return !!content && extUriBiasedIgnorePathCase.isEqual(isAbsolute(content) ? URI.file(content) : joinPath(worktreeGitDir, content), joinPath(folder, '.git')); } } + +/** The branch a git HEAD names (`ref: refs/heads/`), the short commit of a detached one, or `undefined`. */ +export function parseGitHead(head: string): string | undefined { + const content = head.trim(); + const branch = /^ref:\s*refs\/heads\/(?.+)$/.exec(content)?.groups?.branch; + if (branch) { + return branch; + } + return /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i.test(content) ? content.slice(0, 7) : undefined; +} diff --git a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts index 226d8e995e7d8f..023643c1774412 100644 --- a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts +++ b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts @@ -16,10 +16,12 @@ export const IChatViewHostService = createDecorator('chatV export interface IChatViewHostShownSession { readonly resource: URI; /** - * `open` when the user brought the chat up, such as by picking it in the sessions list, starting a new chat or sending - * the first request of one; `restore` when the host shows the chat it last showed, such as as the window starts. + * `open` when the user brought the chat up, such as by picking it in the sessions list or starting a new chat; + * `commit` when the first request of a new chat made it the session it became, which the user did not pick; `restore` + * when the host shows a chat that it does not bring up for the user, such as the chat it last showed as the window + * starts, or one opened with {@link IChatViewHostOpenOptions.restore}. */ - readonly reason: 'open' | 'restore'; + readonly reason: 'open' | 'commit' | 'restore'; } /** How {@link IChatViewHost.reveal} shows the host. */ @@ -35,6 +37,11 @@ export interface IChatViewHostRevealOptions { export interface IChatViewHostOpenOptions { readonly preserveFocus?: boolean; readonly sessionTypeSelectionReason?: SessionTypeSelectionReason; + /** + * Shows the session as one the host restores ({@link IChatViewHostShownSession.reason} `restore`) rather than one + * the user opens, such as the chat that was last shown where the user moved the window's folder. + */ + readonly restore?: boolean; } /** How {@link IChatViewHost.startSession} starts a session that its caller may send a first request to. */ diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts index fd9906d05b0d13..0769a48767e0d5 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts @@ -53,16 +53,20 @@ export class ChatViewPaneHost implements IChatViewHost { if (view && !options?.preserveFocus) { view.focus(); } - view?.reportOpened(sessionResource); + if (!options?.restore) { + view?.reportOpened(sessionResource); + } return view?.widget; } async openSession(sessionResource: URI, options?: IChatViewHostOpenOptions): Promise { const chatView = await this.viewsService.openView(ChatViewId, !options?.preserveFocus); if (chatView) { - await chatView.loadSession(sessionResource, options?.sessionTypeSelectionReason); + await chatView.loadSession(sessionResource, options?.sessionTypeSelectionReason, options?.restore ? 'restore' : 'open'); // Also a chat it showed already, which loading does not set again - chatView.reportOpened(sessionResource); + if (!options?.restore) { + chatView.reportOpened(sessionResource); + } if (!options?.preserveFocus) { chatView.focusInput(); } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index f906948705e79a..433bd71e7c30f3 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -345,7 +345,8 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } const modelRef = await this.chatService.acquireOrLoadSession(e.committed, ChatAgentLocation.Chat, CancellationToken.None, 'ChatViewPane#onDidCommitSession'); - await this.showModel(CancellationToken.None, modelRef); + // The chat the first request made: a session the user did not pick + await this.showModel(CancellationToken.None, modelRef, undefined, undefined, undefined, undefined, 'commit'); })); // Layout changes @@ -1519,7 +1520,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { this._shownSession.set(shownResource && { resource: shownResource, reason: this._showReason }, undefined); } - /** @param showReason why it shows the chat ({@link shownSession}): only the view's own restore passes `restore`. */ + /** @param showReason why it shows the chat ({@link shownSession}). */ private async showModel(token: CancellationToken, modelRef?: IChatModelReference | undefined, startNewSession = true, ignoreTransferredSession = false, inputBeforeLoad?: string, localFallbackSelectionReason?: SessionTypeSelectionReason, showReason: IChatViewHostShownSession['reason'] = 'open'): Promise { if (token.isCancellationRequested || this._store.isDisposed) { modelRef?.dispose(); @@ -1672,7 +1673,8 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { this.updateActions(); } - async loadSession(sessionResource: URI, sessionTypeSelectionReason?: SessionTypeSelectionReason): Promise { + /** @param showReason why it shows the chat ({@link shownSession}). */ + async loadSession(sessionResource: URI, sessionTypeSelectionReason?: SessionTypeSelectionReason, showReason: 'open' | 'restore' = 'open'): Promise { const t0 = Date.now(); this.logService.trace(`[ChatViewPane] loadSession start uri=${sessionResource.toString()}`); @@ -1730,7 +1732,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } const localFallbackSelectionReason = getLocalFallbackSessionTypeSelectionReason(getChatSessionType(sessionResource), !!newModelRef); - const result = await this.showModel(token, newModelRef, true, false, inputBeforeLoad, localFallbackSelectionReason); + const result = await this.showModel(token, newModelRef, true, false, inputBeforeLoad, localFallbackSelectionReason, showReason); this.logService.trace(`[ChatViewPane] loadSession done total=${Date.now() - t0}ms uri=${sessionResource.toString()}`); return result; } catch (err) { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts index 174b5726638b09..0c1af172077e39 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts @@ -28,7 +28,7 @@ suite('AgentHostWorktreeLocations', () => { * A provider over git entries: `true` is a `.git` directory, and a string the content of a file, such as a worktree's * `.git` file or its entry's `gitdir` file in the repository. */ - function createProvider(entries: Record, sessions: readonly IAgentSession[], uncached: readonly IAgentSession[] = []) { + function createProvider(entries: Record, sessions: readonly IAgentSession[], uncached: readonly IAgentSession[] = [], links: Record = {}) { const fileService = upcastPartial({ stat: async (resource: URI) => { const entry = entries[resource.path]; @@ -37,6 +37,11 @@ suite('AgentHostWorktreeLocations', () => { } return upcastPartial({ isDirectory: entry === true }); }, + // A path through a symbolic link, such as `links['/link/vscode'] = '/work/vscode'`, is the path it links to + realpath: async (resource: URI) => { + const link = Object.keys(links).find(path => resource.path === path || resource.path.startsWith(`${path}/`)); + return link ? URI.file(links[link] + resource.path.slice(link.length)) : resource; + }, readFile: async (resource: URI) => { const entry = entries[resource.path]; if (typeof entry !== 'string') { @@ -59,6 +64,11 @@ suite('AgentHostWorktreeLocations', () => { // A worktree whose repository entry names it back '/work/vscode.worktrees/feature/.git': 'gitdir: /work/vscode/.git/worktrees/feature\n', '/work/vscode/.git/worktrees/feature/gitdir': '/work/vscode.worktrees/feature/.git\n', + '/work/vscode/.git/worktrees/feature/HEAD': 'ref: refs/heads/agents/feature\n', + // A worktree at a detached commit + '/work/vscode.worktrees/detached/.git': 'gitdir: /work/vscode/.git/worktrees/detached\n', + '/work/vscode/.git/worktrees/detached/gitdir': '/work/vscode.worktrees/detached/.git\n', + '/work/vscode/.git/worktrees/detached/HEAD': '0123456789abcdef0123456789abcdef01234567\n', // One with a relative link back '/work/vscode.worktrees/relative/.git': 'gitdir: /work/vscode/.git/worktrees/relative', '/work/vscode/.git/worktrees/relative/gitdir': '../../../../vscode.worktrees/relative/.git', @@ -118,4 +128,21 @@ suite('AgentHostWorktreeLocations', () => { const locations = await provider.provideLocations(URI.file('/work/vscode'), CancellationToken.None); assert.deepStrictEqual(locations.map(location => location.label), ['feature', 'relative']); }); + + test('a worktree is described by its branch, the short commit of a detached HEAD, or nothing without HEAD', async () => { + const provider = createProvider(repository, []); + const describe = async (path: string) => (await provider.resolveLocation(URI.file('/work/vscode'), URI.file(path), CancellationToken.None))?.description; + assert.deepStrictEqual({ branch: await describe('/work/vscode.worktrees/feature'), detached: await describe('/work/vscode.worktrees/detached'), withoutHead: await describe('/work/vscode.worktrees/relative') }, { branch: 'agents/feature', detached: '0123456', withoutHead: undefined }); + }); + + test('compares the folder and its worktrees by their real paths, such as through a symbolic link', async () => { + const provider = createProvider(repository, [session(SessionType.AgentHostCopilot, '/alias/feature')], [], { '/link/vscode': '/work/vscode', '/alias/feature': '/work/vscode.worktrees/feature' }); + const resolve = async (primary: string, location: string) => (await provider.resolveLocation(URI.file(primary), URI.file(location), CancellationToken.None))?.label ?? 'none'; + assert.deepStrictEqual({ + throughLinkedPrimary: await resolve('/link/vscode', '/work/vscode.worktrees/feature'), + linkedWorktree: await resolve('/work/vscode', '/alias/feature'), + primaryItself: await resolve('/link/vscode', '/work/vscode'), + offered: (await provider.provideLocations(URI.file('/link/vscode'), CancellationToken.None)).map(location => location.uri.path), + }, { throughLinkedPrimary: 'feature', linkedWorktree: 'feature', primaryItself: 'none', offered: ['/alias/feature'] }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts index abbfa90a26e95f..60c1a5f36aff1d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts @@ -39,7 +39,7 @@ suite('ChatViewHostService', () => { shownSession, widget: options.widgetCreatedLater ? undefined : widget, get whenWidget() { accesses++; return created.p; }, - loadSession: async (resource: URI, reason?: string) => { calls.push(`loadSession ${resource.toString()} reason=${reason}`); return undefined; }, + loadSession: async (resource: URI, reason?: string, showReason?: string) => { calls.push(`loadSession ${resource.toString()} reason=${reason}${showReason === 'restore' ? ' restore' : ''}`); return undefined; }, startNewLocalSession: async () => { calls.push('startNewLocalSession'); return undefined; }, focus: () => { calls.push('focus'); }, focusInput: () => { calls.push('focusInput'); }, @@ -70,6 +70,8 @@ suite('ChatViewHostService', () => { results.reveal = await service.reveal(false) === widget; results.revealPreservingFocus = await service.reveal(true) === widget; results.openSession = await service.openSession(resource, { sessionTypeSelectionReason: 'explicitOverride' }) === widget; + // One to restore is not reported as opened + await service.openSession(resource, { preserveFocus: true, restore: true }); results.toggle = await service.toggle(); const start = await service.startSession(resource, { local: false, importingConversation: true, beforeOpen: () => calls.push('beforeOpen') }); await start.moved(URI.parse('test:/moved')); @@ -85,6 +87,7 @@ suite('ChatViewHostService', () => { `openView ${ChatViewId} focus=true`, `openView ${ChatViewId} focus=false`, `openView ${ChatViewId} focus=true`, 'loadSession test:/session reason=explicitOverride', 'reportOpened test:/session', 'focusInput', + `openView ${ChatViewId} focus=false`, 'loadSession test:/session reason=undefined restore', `setPartHidden false ${Parts.AUXILIARYBAR_PART}`, `openView ${ChatViewId} focus=undefined`, 'beforeOpen', 'suppress', 'progress', 'loadSession test:/session reason=explicitOverride', 'focus', `openView ${ChatViewId} focus=undefined`, 'loadSession test:/moved reason=undefined', diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index 4e888b7ec9db8c..0c0bddde5e2e63 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -202,7 +202,7 @@ suite('ChatViewPane session transitions', () => { const nextRef = createReference(nextModel); const pane: { showModel(token: CancellationToken, ref: IChatModelReference): Promise; - loadSession(resource: URI): Promise; + loadSession(resource: URI, sessionTypeSelectionReason?: undefined, showReason?: 'open' | 'restore'): Promise; applyModel(): void; restoringSession: Promise | undefined; readonly shownSession: IObservable; @@ -299,6 +299,11 @@ suite('ChatViewPane session transitions', () => { Object.assign(restored.pane, { chatService: { acquireOrLoadSession: async () => savedReference() } }); await restored.pane.loadSession(savedResource); const afterLoad = shown(restored); + // A chat loaded as one to restore, such as the last chat of where the user moved the folder + const recalledResource = nextResource.with({ path: '/recalled' }); + Object.assign(restored.pane, { chatService: { acquireOrLoadSession: async () => Object.assign(toDisposable(() => { }), { object: upcastPartial({ sessionResource: recalledResource }) }) } }); + await restored.pane.loadSession(recalledResource, undefined, 'restore'); + const loadedToRestore = shown(restored); // The user loads a chat while the restore still prepares its chat's configuration const preparing = new DeferredPromise(); @@ -315,11 +320,12 @@ suite('ChatViewPane session transitions', () => { await loading; await restoration; - assert.deepStrictEqual({ afterRestore, otherReported, reopened, afterLoad, superseded: shown(superseded) }, { + assert.deepStrictEqual({ afterRestore, otherReported, reopened, afterLoad, loadedToRestore, superseded: shown(superseded) }, { afterRestore: '/untitled-next restore', otherReported: '/untitled-next restore', reopened: '/untitled-next open', afterLoad: '/saved open', + loadedToRestore: '/recalled restore', superseded: '/saved open', }); }); From 5f56e14b23c992dff4cbbe197b63ad8f1b0ca939 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:44:32 +0100 Subject: [PATCH 036/165] Worktree support: list the worktree the folder is at in the folder chip, also one no session lists Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostWorktreeFolderChoices.ts | 10 +++++++--- .../agentHost/agentHostWorktreeFolderChoices.test.ts | 12 ++++++++---- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts index 7fd78cb491eea9..120321ce792104 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts @@ -156,11 +156,15 @@ export class AgentHostWorktreeFolderChoices extends Disposable { return []; } const isCurrent = (uri: URI) => extUriBiasedIgnorePathCase.isEqual(uri, folder.uri); + const locations: IFolderLocation[] = this._locations.filter(location => !extUriBiasedIgnorePathCase.isEqual(location.uri, primary)); + // Where the folder is now, also a worktree that no session works in any more + const current = this._folderLocationService.getLocation(folder); + if (current && !isCurrent(primary) && !locations.some(location => isCurrent(location.uri))) { + locations.push(current); + } return [ { uri: primary, label: basename(primary), kind: 'folder', current: isCurrent(primary) }, - ...this._locations - .filter(location => !extUriBiasedIgnorePathCase.isEqual(location.uri, primary)) - .map(location => ({ uri: location.uri, label: location.label, kind: 'worktree' as const, current: isCurrent(location.uri) })), + ...locations.map(location => ({ uri: location.uri, label: location.label, kind: 'worktree' as const, current: isCurrent(location.uri) })), ]; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts index 450f6da2c9f062..f7756fccd94263 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts @@ -12,7 +12,7 @@ import { MenuItemAction } from '../../../../../../../platform/actions/common/act import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; -import { IFolderLocation, IWorkspaceFolderLocationService } from '../../../../../../../platform/workspace/common/folderLocations.js'; +import { IFolderLocation, IResolvedFolderLocation, IWorkspaceFolderLocationService } from '../../../../../../../platform/workspace/common/folderLocations.js'; import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder, WorkbenchState } from '../../../../../../../platform/workspace/common/workspace.js'; import { IAgentHostNewSessionFolderService } from '../../../../browser/agentSessions/agentHost/agentHostNewSessionFolderService.js'; import { IAgentHostUntitledProvisionalSessionService } from '../../../../browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; @@ -29,7 +29,7 @@ suite('AgentHostWorktreeFolderChoices', () => { const session = URI.from({ scheme: SessionType.AgentHostCopilot, path: '/untitled-1' }); /** The choices in a window whose folder is at `at`, recording the folders and isolation it sets for the session. */ - async function createChoices(options: { readonly enabled: boolean; readonly at: URI; readonly isolation?: string; readonly state?: WorkbenchState }) { + async function createChoices(options: { readonly enabled: boolean; readonly at: URI; readonly isolation?: string; readonly state?: WorkbenchState; readonly listed?: readonly URI[] }) { const config = { values: { isolation: options.isolation ?? 'folder' } as Record }; const changes: string[] = []; const instantiationService = store.add(new TestInstantiationService()); @@ -42,7 +42,8 @@ suite('AgentHostWorktreeFolderChoices', () => { onDidChangeProviders: Event.None, onDidChangeLocation: Event.None, canSetLocation: () => true, - getLocations: async () => [feature, other].map(uri => upcastPartial({ uri, label: uri.path.split('/').pop() })), + getLocations: async () => (options.listed ?? [feature, other]).map(uri => upcastPartial({ uri, label: uri.path.split('/').pop() })), + getLocation: () => options.at.path === primary.path ? undefined : upcastPartial({ uri: options.at, label: options.at.path.split('/').pop() }), })); instantiationService.stub(IAgentHostNewSessionFolderService, upcastPartial({ getFolder: () => undefined, @@ -68,9 +69,12 @@ suite('AgentHostWorktreeFolderChoices', () => { test('offers New Worktree first, then the folder and its worktrees, each section with where the folder is now first', async () => { const atWorktree = await createChoices({ enabled: true, at: other }); const atFolder = await createChoices({ enabled: true, at: primary }); + // A worktree that no session lists any more, such as one whose sessions are archived + const atUnlisted = await createChoices({ enabled: true, at: other, listed: [feature] }); - assert.deepStrictEqual({ atWorktree: atWorktree.items(other), atFolder: atFolder.items(primary) }, { + assert.deepStrictEqual({ atWorktree: atWorktree.items(other), atUnlisted: atUnlisted.items(other), atFolder: atFolder.items(primary) }, { atWorktree: ['New Worktree', '[Folder] vscode', '[Worktrees] other (Current) *', '[Worktrees] feature'], + atUnlisted: ['New Worktree', '[Folder] vscode', '[Worktrees] other (Current) *', '[Worktrees] feature'], atFolder: ['New Worktree', '[Folder] vscode (Current) *', '[Worktrees] feature', '[Worktrees] other'], }); }); From 039d4528da2ed20c74b9bef11ddf6bf0f4967204 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:47:46 +0100 Subject: [PATCH 037/165] Worktree infra: an open to restore the chat that a host shows already reports it as restored Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../widgetHosts/viewPane/chatViewHostService.ts | 8 ++++++-- .../browser/widgetHosts/viewPane/chatViewPane.ts | 16 ++++++++++++++-- .../viewPane/chatViewHostService.test.ts | 3 ++- .../widgetHosts/viewPane/chatViewPane.test.ts | 8 +++++++- 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts index 0769a48767e0d5..909d034271a63f 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts @@ -53,7 +53,9 @@ export class ChatViewPaneHost implements IChatViewHost { if (view && !options?.preserveFocus) { view.focus(); } - if (!options?.restore) { + if (options?.restore) { + view?.reportRestored(sessionResource); + } else { view?.reportOpened(sessionResource); } return view?.widget; @@ -64,7 +66,9 @@ export class ChatViewPaneHost implements IChatViewHost { if (chatView) { await chatView.loadSession(sessionResource, options?.sessionTypeSelectionReason, options?.restore ? 'restore' : 'open'); // Also a chat it showed already, which loading does not set again - if (!options?.restore) { + if (options?.restore) { + chatView.reportRestored(sessionResource); + } else { chatView.reportOpened(sessionResource); } if (!options?.preserveFocus) { diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 433bd71e7c30f3..78f9ac81e7e7cb 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -1508,9 +1508,21 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { * one it restored that the user picks in a sessions list. */ reportOpened(sessionResource: URI): void { + this._reportShown(sessionResource, 'open'); + } + + /** + * Reports the chat of `sessionResource` as one that it restores, also where the view shows it already as opened, such + * as the last chat of where the user moved the window's folder. + */ + reportRestored(sessionResource: URI): void { + this._reportShown(sessionResource, 'restore'); + } + + private _reportShown(sessionResource: URI, reason: 'open' | 'restore'): void { const shown = this._shownSession.get(); - if (shown?.reason === 'restore' && isEqual(shown.resource, sessionResource)) { - this._shownSession.set({ resource: sessionResource, reason: 'open' }, undefined); + if (shown && shown.reason !== reason && isEqual(shown.resource, sessionResource)) { + this._shownSession.set({ resource: sessionResource, reason }, undefined); } } diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts index 60c1a5f36aff1d..98e0d28e5c0f94 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts @@ -44,6 +44,7 @@ suite('ChatViewHostService', () => { focus: () => { calls.push('focus'); }, focusInput: () => { calls.push('focusInput'); }, reportOpened: (resource: URI) => { calls.push(`reportOpened ${resource.toString()}`); }, + reportRestored: (resource: URI) => { calls.push(`reportRestored ${resource.toString()}`); }, beginSessionsListSuppression: () => { calls.push('suppress'); return toDisposable(() => calls.push('unsuppress')); }, }); const instantiationService = disposables.add(new TestInstantiationService()); @@ -87,7 +88,7 @@ suite('ChatViewHostService', () => { `openView ${ChatViewId} focus=true`, `openView ${ChatViewId} focus=false`, `openView ${ChatViewId} focus=true`, 'loadSession test:/session reason=explicitOverride', 'reportOpened test:/session', 'focusInput', - `openView ${ChatViewId} focus=false`, 'loadSession test:/session reason=undefined restore', + `openView ${ChatViewId} focus=false`, 'loadSession test:/session reason=undefined restore', 'reportRestored test:/session', `setPartHidden false ${Parts.AUXILIARYBAR_PART}`, `openView ${ChatViewId} focus=undefined`, 'beforeOpen', 'suppress', 'progress', 'loadSession test:/session reason=explicitOverride', 'focus', `openView ${ChatViewId} focus=undefined`, 'loadSession test:/moved reason=undefined', diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index 0c0bddde5e2e63..080b0074bbf8df 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -207,6 +207,7 @@ suite('ChatViewPane session transitions', () => { restoringSession: Promise | undefined; readonly shownSession: IObservable; reportOpened(sessionResource: URI): void; + reportRestored(sessionResource: URI): void; } = Object.assign(Object.create(ChatViewPane.prototype), { _store: paneStore, _showReason: 'open', @@ -304,6 +305,10 @@ suite('ChatViewPane session transitions', () => { Object.assign(restored.pane, { chatService: { acquireOrLoadSession: async () => Object.assign(toDisposable(() => { }), { object: upcastPartial({ sessionResource: recalledResource }) }) } }); await restored.pane.loadSession(recalledResource, undefined, 'restore'); const loadedToRestore = shown(restored); + // The chat it shows, opened again as one to restore, such as where it was opened before + restored.pane.reportOpened(recalledResource); + restored.pane.reportRestored(recalledResource); + const reopenedToRestore = shown(restored); // The user loads a chat while the restore still prepares its chat's configuration const preparing = new DeferredPromise(); @@ -320,12 +325,13 @@ suite('ChatViewPane session transitions', () => { await loading; await restoration; - assert.deepStrictEqual({ afterRestore, otherReported, reopened, afterLoad, loadedToRestore, superseded: shown(superseded) }, { + assert.deepStrictEqual({ afterRestore, otherReported, reopened, afterLoad, loadedToRestore, reopenedToRestore, superseded: shown(superseded) }, { afterRestore: '/untitled-next restore', otherReported: '/untitled-next restore', reopened: '/untitled-next open', afterLoad: '/saved open', loadedToRestore: '/recalled restore', + reopenedToRestore: '/recalled restore', superseded: '/saved open', }); }); From 766c36038fc294a4d7fb380bb4cf4dbab7ce7ee1 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:48:34 +0100 Subject: [PATCH 038/165] chat: reveal, toggle and New Chat claim the shown chat; keep it on unrelated removals Revealing a host, a toggle that shows it and New Chat that it takes make it the host whose shown chat the service reports, none while it shows the new chat of New Chat, until it shows a chat. Removing a registration no longer resets the reporting host, unless that host is no longer registered: then the host in use reports. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../viewPane/chatViewHostService.ts | 74 ++++++++++++++----- .../viewPane/chatViewHostService.test.ts | 68 ++++++++++++++++- 2 files changed, 121 insertions(+), 21 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts index fb76660de956af..b27dc1296301cc 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts @@ -145,8 +145,11 @@ export class ChatViewHostService extends Disposable implements IChatViewHostServ /** The registrations of the hosts that replace the Chat view, the one in use last: one each, also of the same host. */ private readonly registrations: { readonly host: IChatViewHost }[] = []; private readonly hostInUse: ISettableObservable; - /** The host that showed a chat last, whose shown chat the service reports. */ - private readonly latestHost: ISettableObservable; + /** + * The host that showed something last, whose shown chat the service reports: none while it shows a new chat that New Chat + * started, until it shows a chat. + */ + private readonly latest: ISettableObservable<{ readonly host: IChatViewHost; readonly newChat: boolean }>; readonly shownSession: IObservable; @@ -156,18 +159,21 @@ export class ChatViewHostService extends Disposable implements IChatViewHostServ super(); this.chatViewHost = instantiationService.createInstance(ChatViewPaneHost); this.hostInUse = observableValue(this, this.chatViewHost); - this.latestHost = observableValue(this, this.chatViewHost); - this.shownSession = derived(this, reader => this.latestHost.read(reader).shownSession?.read(reader)); + this.latest = observableValue(this, { host: this.chatViewHost, newChat: false }); + this.shownSession = derived(this, reader => { + const latest = this.latest.read(reader); + return latest.newChat ? undefined : latest.host.shownSession?.read(reader); + }); } - /** Tracks the chats that the Chat view shows while a host replaces it; without one, the Chat view is always the latest host. */ + /** Tracks the chats that the Chat view shows (see {@link updateChatViewTracking}); otherwise it is the latest host, as it shows them. */ private readonly chatViewTracking = this._register(new MutableDisposable()); /** Makes `host` the latest host whenever it shows another chat, such as one it restores or one opened in it directly. */ private trackShownSession(host: IChatViewHost): IDisposable { return host.shownSession ? runOnChange(host.shownSession, shown => { if (shown) { - this.latestHost.set(host, undefined); + this.claim(host); } }) : Disposable.None; } @@ -175,6 +181,21 @@ export class ChatViewHostService extends Disposable implements IChatViewHostServ private readonly _replaced = observableValue(this, false); readonly replaced: IObservable = this._replaced; + /** Makes `host` the host that showed something last: a new chat of New Chat while `newChat`. */ + private claim(host: IChatViewHost, newChat = false): void { + this.latest.set({ host, newChat }, undefined); + this.updateChatViewTracking(); + } + + /** Tracks the Chat view's shown chat while it may be other than the latest host's, or ends a new chat it shows. */ + private updateChatViewTracking(): void { + if (this.registrations.length === 0 && !this.latest.get().newChat) { + this.chatViewTracking.clear(); + } else if (!this.chatViewTracking.value) { + this.chatViewTracking.value = this.trackShownSession(this.chatViewHost); + } + } + private get host(): IChatViewHost { return this.hostInUse.get(); } @@ -189,6 +210,7 @@ export class ChatViewHostService extends Disposable implements IChatViewHostServ const registration = { host }; this.registrations.push(registration); this.updateHostInUse(); + this.claim(host); const tracking = this.trackShownSession(host); return toDisposable(() => { tracking.dispose(); @@ -196,6 +218,10 @@ export class ChatViewHostService extends Disposable implements IChatViewHostServ if (index >= 0) { this.registrations.splice(index, 1); this.updateHostInUse(); + // The host that showed something last reports it until it is no longer registered: then the host in use does + if (this.latest.get().host === host && !this.registrations.some(other => other.host === host)) { + this.claim(this.host); + } } }); } @@ -203,45 +229,53 @@ export class ChatViewHostService extends Disposable implements IChatViewHostServ private updateHostInUse(): void { const host = this.registrations.at(-1)?.host ?? this.chatViewHost; this.hostInUse.set(host, undefined); - this.latestHost.set(host, undefined); this._replaced.set(this.registrations.length > 0, undefined); - if (this.registrations.length === 0) { - this.chatViewTracking.clear(); - } else if (!this.chatViewTracking.value) { - this.chatViewTracking.value = this.trackShownSession(this.chatViewHost); - } + this.updateChatViewTracking(); } - reveal(preserveFocus?: boolean, options?: IChatViewHostRevealOptions): Promise { - return this.host.reveal(preserveFocus, options); + async reveal(preserveFocus?: boolean, options?: IChatViewHostRevealOptions): Promise { + const host = this.host; + const widget = await host.reveal(preserveFocus, options); + this.claim(host); + return widget; } async revealIfShowing(sessionResource: URI, options?: IChatViewHostOpenOptions): Promise { const host = this.hostOf(sessionResource); const widget = await host.revealIfShowing?.(sessionResource, options); if (widget) { - this.latestHost.set(host, undefined); + this.claim(host); } return widget; } openSession(sessionResource: URI, options?: IChatViewHostOpenOptions): Promise { const host = this.hostOf(sessionResource); - this.latestHost.set(host, undefined); + this.claim(host); return host.openSession(sessionResource, options); } - toggle(): Promise { - return this.host.toggle(); + async toggle(): Promise { + const host = this.host; + const shows = await host.toggle(); + if (shows) { + this.claim(host); + } + return shows; } startSession(sessionResource: URI, options: IChatViewHostSessionStartOptions): Promise { const host = this.hostOf(sessionResource); - this.latestHost.set(host, undefined); + this.claim(host); return host.startSession(sessionResource, options); } newChat(): boolean { - return this.host.newChat?.() ?? false; + const host = this.host; + const taken = host.newChat?.() ?? false; + if (taken) { + this.claim(host, true); + } + return taken; } } diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts index d18ebcf97e34ea..c7324325ad78b9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { DeferredPromise, timeout } from '../../../../../../../base/common/async.js'; import { Emitter } from '../../../../../../../base/common/event.js'; import { toDisposable } from '../../../../../../../base/common/lifecycle.js'; -import { observableValue } from '../../../../../../../base/common/observable.js'; +import { constObservable, observableValue } from '../../../../../../../base/common/observable.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; @@ -255,4 +255,70 @@ suite('ChatViewHostService', () => { chatViewRestores: 'vscode-chat-session restore', }); }); + + test('revealing a host, or New Chat that it takes, makes it the host whose shown chat the service reports, none while it shows a new chat', async () => { + const { service, shownSession, showView } = createService(); + const shown = () => service.shownSession.get()?.resource.scheme ?? 'none'; + const agentHostChat = URI.parse('agent-host-copilot:/session'); + const localChat = URI.parse('vscode-chat-session://local/session'); + shownSession.set({ resource: localChat, reason: 'open' }, undefined); + showView(); + const hostShown = observableValue('hostShown', { resource: agentHostChat, reason: 'open' }); + let takesNewChat = false; + const registration = service.registerHost({ + ...upcastPartial({}), + shownSession: hostShown, + canShow: resource => resource.scheme !== localChat.scheme, + openSession: async () => undefined, + reveal: async () => undefined, + newChat: () => takesNewChat, + }); + const steps: string[] = []; + await service.openSession(localChat); + steps.push(shown()); + // The host shows again, also with the chat it showed before + await service.reveal(); + steps.push(shown()); + await service.openSession(localChat); + // New Chat that the host does not take changes nothing + service.newChat(); + steps.push(shown()); + takesNewChat = true; + service.newChat(); + steps.push(shown()); + // The new chat starts a session + hostShown.set({ resource: agentHostChat.with({ path: '/started' }), reason: 'open' }, undefined); + steps.push(shown()); + registration.dispose(); + + assert.deepStrictEqual(steps, ['vscode-chat-session', 'agent-host-copilot', 'vscode-chat-session', 'none', 'agent-host-copilot']); + }); + + test('the host that showed a chat last reports it until it is no longer registered, also as other hosts are removed', async () => { + const { service, shownSession, showView } = createService(); + const shown = () => service.shownSession.get()?.resource.path ?? 'none'; + const localChat = URI.parse('vscode-chat-session://local/chat-view'); + shownSession.set({ resource: localChat, reason: 'open' }, undefined); + showView(); + const createHost = (name: string) => ({ + ...upcastPartial({}), + shownSession: constObservable({ resource: URI.parse(`agent-host-copilot:/${name}`), reason: 'open' as const }), + canShow: (resource: URI) => resource.scheme !== localChat.scheme, + openSession: async () => undefined, + }); + const a = service.registerHost(createHost('a')); + const b = service.registerHost(createHost('b')); + const steps: string[] = []; + // The Chat view shows a chat that B leaves to it + await service.openSession(localChat); + steps.push(shown()); + a.dispose(); + steps.push(shown()); + await service.openSession(URI.parse('agent-host-copilot:/b')); + steps.push(shown()); + b.dispose(); + steps.push(shown()); + + assert.deepStrictEqual(steps, ['/chat-view', '/chat-view', '/b', '/chat-view']); + }); }); From faa486d9f188a80d0621b8f6328d89a0987841f2 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:51:40 +0100 Subject: [PATCH 039/165] Worktree support: address code review - Log the experiment trigger once a window with AI features has a folder that can move, before any surface differs. - Follow a working directory that changes while an earlier automatic follow is under way, once that settles. - Mark a terminal by the most specific location it started in, such as a worktree inside the folder. - Refresh the folder chip as sessions change, and reset New Worktree also after the folder change resets the session's configuration. - Describe the worktree choices in the chat accessibility help. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/actions/chatAccessibilityHelp.ts | 1 + .../agentHostWorktree.contribution.ts | 39 ++++++++++++++----- .../agentHostWorktreeFolderChoices.ts | 19 +++++++-- .../agentHost/agentHostWorktree.test.ts | 38 ++++++++++++++++++ .../agentHostWorktreeFolderChoices.test.ts | 31 ++++++++++++--- 5 files changed, 109 insertions(+), 19 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index 38ad3a915ac925..325f6ead04cd36 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -204,6 +204,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui content.push(localize('chat.externalSessionFilter', 'The agent sessions filter includes an External submenu. Use it to choose whether external sessions from another application are shown for the last 24 hours, the last 7 days, always, or not at all.')); content.push(localize('workbench.action.openAgentsWindow', "To open the Agents Window, invoke the Open Agents Window command{0}. The default keybinding is enabled after you create your first session in the Agents Window. In screen reader mode, this keybinding includes Alt to avoid conflicts with screen reader shortcuts.", '')); content.push(localize('workbench.action.chat.openAgentHostFolderPicker', 'When starting an agent session in a multi-root workspace, you can choose which root folder it runs in by invoking the Folder command{0}, then selecting a folder from the list.', '')); + content.push(localize('chat.editorWindowWorktree', "When {0} is on in a window with a single local folder, the Folder picker of a new agent session also lists New Worktree, then the folder and the worktrees that sessions work in, in Folder and Worktrees sections. Where the folder is now is described as Current. Use Up or Down Arrow to choose and Enter to select. New Worktree starts the session in a new worktree of the folder. When you open a chat, the window's folder moves to the worktree the chat works in, or back to the folder for a local chat, and the window's title shows where the folder is.", ChatConfiguration.EditorWindowEnableWorktree)); content.push(localize('chat.agentHostApprovalsPicker', 'When an agent session exposes approval presets, use Tab to reach the Approvals picker and choose how it handles workspace access, commands, and the internet.')); content.push(localize('chat.agentHostSandboxPolicy', "In Copilot agent-host sessions, use /sandbox policy to view the effective sandbox policy. In the response, use Tab to focus Open Sandbox Policy and Enter to open the formatted report.")); content.push(localize('chat.agentHostSandboxDiagnostics', "When a sandbox dependency warning appears above the input, use Tab to reach Troubleshooting Guide or, when permitted, Disable Sandboxing. Press Enter or Space to activate the button. Disable Sandboxing turns sandboxing off for this session and in settings; your organization's requirements still apply.")); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts index 6b59fccf57c0fc..b2b107e51a606c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts @@ -98,6 +98,8 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe private _foreground = 0; /** The switches of its own under way, from their request to the workspace's answer. */ private _switching = 0; + /** An automatic follow that gave way to a switch under way: it follows once that settles, unless a later choice came. */ + private _followWhenIdle: { readonly shown: IShownChat; readonly request: number } | undefined; /** The moves it asked the workspace for that have not completed yet. */ private _pendingMoves = 0; /** Cancels what switches read, such as whether a folder exists, as it is disposed. */ @@ -150,6 +152,7 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe })); this._updateEnablement(); + this._logTrigger(); this._register(_configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(ChatConfiguration.EditorWindowEnableWorktree)) { this._updateEnablement(); @@ -158,6 +161,7 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe this._register(_contextKeyService.onDidChangeContext(e => { if (e.affectsSome(CHAT_ENABLED_KEYS)) { this._updateEnablement(); + this._logTrigger(); } })); @@ -167,9 +171,6 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe // A chat the user opens follows; so does one that a first request made, on its own, as the user did not pick it const follows = shown?.reason === 'open' || shown?.reason === 'commit'; this._shown = shown && !isUntitledChatSession(shown.resource) ? { resource: shown.resource, workingDirectory: undefined, followOnceKnown: follows } : undefined; - if (shown) { - this._logTrigger(shown.resource); - } if (!shown || shown.reason === 'open') { // The user's latest choice: no switch asked for before applies, also where this chat moves nothing this._request++; @@ -191,8 +192,12 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe this._register(_workspaceContextService.onDidChangeWorkspaceFolders(() => { this._foreground++; this._markTerminals(); + this._logTrigger(); + })); + this._register(_workspaceContextService.onDidChangeWorkbenchState(() => { + this._foreground++; + this._logTrigger(); })); - this._register(_workspaceContextService.onDidChangeWorkbenchState(() => this._foreground++)); this._register(_folderLocationService.onDidChangeLocation(e => { this._foreground++; const own = this._ownMove && extUriBiasedIgnorePathCase.isEqual(this._ownMove, e.folder.uri); @@ -312,18 +317,21 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe return; } if (automatic && this._switching > 0) { + // Gives way to the switch under way, and follows where the chat works by then once that settles + this._followWhenIdle = { shown, request: this._request }; return; } void this._switch(shown.resource, primary, target, automatic ? this._foreground : undefined); } /** - * Logs the setting's experiment trigger as a chat that it can follow shows in a window whose folder can move, before the - * folder follows it or the chat's folder chip offers worktrees: the same moment with the setting on and off. + * Logs the setting's experiment trigger, with the setting on and off, once a window with AI features has a folder that can + * move: before any surface can differ, such as the chats it follows, the chat's folder chip and the window's Folder + * Location picker, which offer the worktrees. */ - private _logTrigger(resource: URI): void { + private _logTrigger(): void { const folder = this._workspaceContextService.getWorkspace().folders[0]; - if (folder && this._folderLocationService.canSetLocation(folder) && (LocalChatSessionUri.isLocalSession(resource) || isLocalAgentHostTarget(resource.scheme))) { + if (folder && this._folderLocationService.canSetLocation(folder) && this._contextKeyService.getContextKeyValue(ChatContextKeys.enabled.key)) { logSettingExperimentTrigger(this._telemetryService, ChatConfiguration.EditorWindowEnableWorktree); } } @@ -401,6 +409,13 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe } } finally { this._switching--; + const followWhenIdle = this._switching === 0 ? this._followWhenIdle : undefined; + if (followWhenIdle) { + this._followWhenIdle = undefined; + if (followWhenIdle.shown === this._shown && followWhenIdle.request === this._request && !this._store.isDisposed) { + this._follow(followWhenIdle.shown, true); + } + } } } @@ -483,8 +498,12 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe private _markTerminal(instance: ITerminalInstance): void { const folder = this._workspaceContextService.getWorkspace().folders[0]; const cwd = instance.initialCwd ? URI.file(instance.initialCwd) : undefined; - const startedAt = cwd && this._visitedLocations.find(location => extUriBiasedIgnorePathCase.isEqualOrParent(cwd, location)); - if (folder && cwd && startedAt && !extUriBiasedIgnorePathCase.isEqualOrParent(cwd, folder.uri)) { + // The checkout it started in: the most specific location the folder has been at, or is at, that holds its cwd, such + // as a worktree inside the folder's own location + const startedAt = cwd && folder && [...this._visitedLocations, folder.uri] + .filter(location => extUriBiasedIgnorePathCase.isEqualOrParent(cwd, location)) + .sort((a, b) => b.path.length - a.path.length)[0]; + if (folder && startedAt && !extUriBiasedIgnorePathCase.isEqual(startedAt, folder.uri)) { instance.statusList.add({ id: FOLDER_LOCATION_TERMINAL_STATUS_ID, severity: Severity.Info, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts index 120321ce792104..5abee00c49cfce 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { RunOnceScheduler } from '../../../../../../base/common/async.js'; import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { onUnexpectedError } from '../../../../../../base/common/errors.js'; @@ -19,6 +20,7 @@ import { IFolderLocation, IWorkspaceFolderLocationService } from '../../../../.. import { IWorkspaceContextService, WorkbenchState } from '../../../../../../platform/workspace/common/workspace.js'; import { getCurrentFolderLocationDescription, getNewWorktreeLabel, groupFolderLocations } from '../../../../../services/workspaces/common/folderLocationSections.js'; import { ChatConfiguration } from '../../../common/constants.js'; +import { IAgentSessionsService } from '../agentSessionsService.js'; import { IAgentHostNewSessionFolderService } from './agentHostNewSessionFolderService.js'; import { getLocalAgentHostSessionProvider } from './agentHostSessionUri.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; @@ -54,10 +56,18 @@ export class AgentHostWorktreeFolderChoices extends Disposable { @IWorkspaceFolderLocationService private readonly _folderLocationService: IWorkspaceFolderLocationService, @IAgentHostNewSessionFolderService private readonly _newSessionFolderService: IAgentHostNewSessionFolderService, @IAgentHostUntitledProvisionalSessionService private readonly _provisional: IAgentHostUntitledProvisionalSessionService, + @IAgentSessionsService agentSessionsService: IAgentSessionsService, ) { super(); this._register(_folderLocationService.onDidChangeProviders(() => this.refresh())); this._register(_folderLocationService.onDidChangeLocation(() => this.refresh())); + // A session can come to work in a new worktree meanwhile, such as one in the background + const refreshForSessions = this._register(new RunOnceScheduler(() => this.refresh(), 500)); + this._register(agentSessionsService.model.onDidChangeSessions(() => { + if (this.isActive()) { + refreshForSessions.schedule(); + } + })); this._register(_provisional.onDidChange(() => this._onDidChange.fire())); this.refresh(); } @@ -135,10 +145,12 @@ export class AgentHostWorktreeFolderChoices extends Disposable { category: { label: section.title, order: index + 1, showHeader: true }, run: async () => { const sessionResource = this._sessionResource(); + // Read before the folder changes, which resets the session's configuration + const isolation = this._isolation(); if (sessionResource) { this._newSessionFolderService.setFolder(sessionResource, choice.uri); } - await this._setIsolation(false); + await this._setIsolation(false, isolation, choice.uri); onDidPick(); }, }); @@ -181,15 +193,14 @@ export class AgentHostWorktreeFolderChoices extends Disposable { } /** Has the session work in a new worktree of its folder, or in the folder itself. */ - private async _setIsolation(worktree: boolean): Promise { + private async _setIsolation(worktree: boolean, isolation = this._isolation(), folder?: URI): Promise { const sessionResource = this._sessionResource(); const provider = sessionResource && getLocalAgentHostSessionProvider(sessionResource); - const isolation = this._isolation(); const value = worktree ? (isolation?.worktree ? 'worktree' : undefined) : isolation?.inPlace; if (!sessionResource || !provider || !isolation || !value || isolation.values[isolation.key] === value) { return; } - const workingDirectory = this._newSessionFolderService.getFolder(sessionResource) ?? this._workspaceContextService.getWorkspace().folders[0]?.uri; + const workingDirectory = folder ?? this._newSessionFolderService.getFolder(sessionResource) ?? this._workspaceContextService.getWorkspace().folders[0]?.uri; await this._provisional.applyConfigChange(sessionResource, provider, workingDirectory, { [isolation.key]: value }); this._onDidChange.fire(); } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts index 9f6fd5e66f6a65..6e43c91ed70d28 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts @@ -413,4 +413,42 @@ suite('AgentHostWorktree', () => { held: { moves: ['/work/vscode.worktrees/feature', '/work/vscode.worktrees/created (gave way)'], applied: ['/work/vscode.worktrees/feature'] }, }); }); + + test('logs the trigger as a window with a folder that can move starts, in both arms, before any chat shows', async () => { + const on = createWorktree({ enabled: true }); + const off = createWorktree({ enabled: false }); + const notMovable = createWorktree({ enabled: true, canSetLocation: false }); + + assert.deepStrictEqual({ on: on.triggers, off: off.triggers, notMovable: notMovable.triggers }, { + on: [`config.${ChatConfiguration.EditorWindowEnableWorktree}`], + off: [`config.${ChatConfiguration.EditorWindowEnableWorktree}`], + notMovable: [], + }); + }); + + test('a working directory that changes while it follows the previous one is followed once that settles', async () => { + const setting = new DeferredPromise(); + const feature = createWorktree({ enabled: true, setting: setting.p }); + feature.show(agentSession('new')); + await settle(); + feature.workingDirectories.set(agentSession('new').toString(), worktree.fsPath); + feature.onDidChangeSessions.fire(); + await settle(); + feature.workingDirectories.set(agentSession('new').toString(), created.fsPath); + feature.onDidChangeSessions.fire(); + await settle(); + setting.complete(); + await settle(); + await settle(); + + assert.deepStrictEqual(feature.applied, ['/work/vscode.worktrees/feature', '/work/vscode.worktrees/created']); + }); + + test('marks a terminal that started in a worktree inside the folder once the folder returns to its own location', async () => { + const nested = URI.file('/work/vscode/.worktrees/nested'); + const feature = createWorktree({ enabled: true, at: nested, worktrees: [nested], terminalCwd: URI.file('/work/vscode/.worktrees/nested/src') }); + feature.userMovesTo(primary); + + assert.deepStrictEqual(feature.marks.at(-1), 'marked'); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts index f7756fccd94263..a3c77bab8dd1b2 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Event } from '../../../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../../../base/common/event.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../../../base/test/common/mock.js'; +import { runWithFakedTimers } from '../../../../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { MenuItemAction } from '../../../../../../../platform/actions/common/actions.js'; import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; @@ -17,6 +18,7 @@ import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder, WorkbenchState import { IAgentHostNewSessionFolderService } from '../../../../browser/agentSessions/agentHost/agentHostNewSessionFolderService.js'; import { IAgentHostUntitledProvisionalSessionService } from '../../../../browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; import { AgentHostWorktreeFolderChoices } from '../../../../browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.js'; +import { IAgentSessionsService } from '../../../../browser/agentSessions/agentSessionsService.js'; import { SessionType } from '../../../../common/chatSessionsService.js'; import { ChatConfiguration } from '../../../../common/constants.js'; @@ -32,6 +34,10 @@ suite('AgentHostWorktreeFolderChoices', () => { async function createChoices(options: { readonly enabled: boolean; readonly at: URI; readonly isolation?: string; readonly state?: WorkbenchState; readonly listed?: readonly URI[] }) { const config = { values: { isolation: options.isolation ?? 'folder' } as Record }; const changes: string[] = []; + const listed = { locations: options.listed ?? [feature, other] }; + const onDidChangeSessions = store.add(new Emitter()); + /** Changing the session's folder resets its configuration until it applies a change, as the provisional service does. */ + let configReset = false; const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(IConfigurationService, new TestConfigurationService({ [ChatConfiguration.EditorWindowEnableWorktree]: options.enabled })); instantiationService.stub(IWorkspaceContextService, upcastPartial({ @@ -42,28 +48,30 @@ suite('AgentHostWorktreeFolderChoices', () => { onDidChangeProviders: Event.None, onDidChangeLocation: Event.None, canSetLocation: () => true, - getLocations: async () => (options.listed ?? [feature, other]).map(uri => upcastPartial({ uri, label: uri.path.split('/').pop() })), + getLocations: async () => listed.locations.map(uri => upcastPartial({ uri, label: uri.path.split('/').pop() })), getLocation: () => options.at.path === primary.path ? undefined : upcastPartial({ uri: options.at, label: options.at.path.split('/').pop() }), })); instantiationService.stub(IAgentHostNewSessionFolderService, upcastPartial({ getFolder: () => undefined, - setFolder: (_resource: URI, folder: URI) => { changes.push(`folder ${folder.path}`); }, + setFolder: (_resource: URI, folder: URI) => { changes.push(`folder ${folder.path}`); configReset = true; }, })); instantiationService.stub(IAgentHostUntitledProvisionalSessionService, upcastPartial({ onDidChange: Event.None, - getResolvedConfig: () => ({ schema: { type: 'object', properties: { isolation: { type: 'string', title: 'Isolation', enum: ['folder', 'worktree'] } } }, values: config.values }), + getResolvedConfig: () => configReset ? undefined : ({ schema: { type: 'object', properties: { isolation: { type: 'string', title: 'Isolation', enum: ['folder', 'worktree'] } } }, values: config.values }), getPreparedSessionConfig: () => undefined, applyConfigChange: async (_resource: URI, _provider: string, _workingDirectory: URI | undefined, partial: Record) => { changes.push(`isolation ${partial.isolation}`); config.values = { ...config.values, ...partial }; + configReset = false; return session; }, })); + instantiationService.stub(IAgentSessionsService, upcastPartial({ model: upcastPartial({ onDidChangeSessions: onDidChangeSessions.event }) })); const choices = store.add(instantiationService.createInstance(AgentHostWorktreeFolderChoices, () => session)); await new Promise(resolve => setTimeout(resolve, 0)); const actions = (selected: URI) => choices.getActions(upcastPartial({}), selected, () => { }); const items = (selected: URI) => actions(selected).map(action => `${action.category?.label ? `[${action.category.label}] ` : ''}${action.label}${action.description ? ` (${action.description})` : ''}${action.checked ? ' *' : ''}`); - return { choices, actions, items, changes }; + return { choices, actions, items, changes, listed, onDidChangeSessions }; } test('offers New Worktree first, then the folder and its worktrees, each section with where the folder is now first', async () => { @@ -99,4 +107,17 @@ suite('AgentHostWorktreeFolderChoices', () => { multiRoot: (await createChoices({ enabled: true, at: primary, state: WorkbenchState.WORKSPACE })).choices.isActive(), }, { on: true, off: false, multiRoot: false }); }); + + test('offers a worktree that a session comes to work in meanwhile, such as one in the background', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { items, listed, onDidChangeSessions } = await createChoices({ enabled: true, at: primary, listed: [feature] }); + const before = items(primary); + listed.locations = [feature, other]; + onDidChangeSessions.fire(); + await new Promise(resolve => setTimeout(resolve, 1000)); + + assert.deepStrictEqual({ before, after: items(primary) }, { + before: ['New Worktree', '[Folder] vscode (Current) *', '[Worktrees] feature'], + after: ['New Worktree', '[Folder] vscode (Current) *', '[Worktrees] feature', '[Worktrees] other'], + }); + })); }); From 6680d51b6561856cda0994cb6c689c021c90c244 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:52:00 +0100 Subject: [PATCH 040/165] Worktree infra: report each explicit open of the chat a view shows, also one reported as opened already Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/widgetHosts/viewPane/chatViewPane.ts | 5 +++-- .../test/browser/widgetHosts/viewPane/chatViewPane.test.ts | 7 ++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 78f9ac81e7e7cb..a5e2465fae8963 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -1505,7 +1505,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { /** * Reports the chat of `sessionResource` as one that the user brought up, also where the view shows it already, such as - * one it restored that the user picks in a sessions list. + * one it restored, or one the user picks again in a sessions list, which is reported again. */ reportOpened(sessionResource: URI): void { this._reportShown(sessionResource, 'open'); @@ -1521,7 +1521,8 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private _reportShown(sessionResource: URI, reason: 'open' | 'restore'): void { const shown = this._shownSession.get(); - if (shown && shown.reason !== reason && isEqual(shown.resource, sessionResource)) { + // Each time the user opens it, also where it is reported as opened already, as where it works may matter again + if (shown && (reason === 'open' || shown.reason !== reason) && isEqual(shown.resource, sessionResource)) { this._shownSession.set({ resource: sessionResource, reason }, undefined); } } diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index 080b0074bbf8df..1aae367c7d6aa2 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -297,6 +297,10 @@ suite('ChatViewPane session transitions', () => { const otherReported = shown(restored); restored.pane.reportOpened(nextResource); const reopened = shown(restored); + // Picked again, such as after the user moved the window's folder away: reported again + const reportedBefore = restored.pane.shownSession.get(); + restored.pane.reportOpened(nextResource); + const reportedAgain = restored.pane.shownSession.get() !== reportedBefore; Object.assign(restored.pane, { chatService: { acquireOrLoadSession: async () => savedReference() } }); await restored.pane.loadSession(savedResource); const afterLoad = shown(restored); @@ -325,10 +329,11 @@ suite('ChatViewPane session transitions', () => { await loading; await restoration; - assert.deepStrictEqual({ afterRestore, otherReported, reopened, afterLoad, loadedToRestore, reopenedToRestore, superseded: shown(superseded) }, { + assert.deepStrictEqual({ afterRestore, otherReported, reopened, reportedAgain, afterLoad, loadedToRestore, reopenedToRestore, superseded: shown(superseded) }, { afterRestore: '/untitled-next restore', otherReported: '/untitled-next restore', reopened: '/untitled-next open', + reportedAgain: true, afterLoad: '/saved open', loadedToRestore: '/recalled restore', reopenedToRestore: '/recalled restore', From b29c10cd5a055dff49f8f9fe631902a0f758cea9 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:52:07 +0100 Subject: [PATCH 041/165] Chat part: review fixes (registrations, superseded starts, restore provenance) and the 'commit' and restore seams of #348304 - IEditorWindowChatPartService.enable keeps one registration per call, also of the same options. - AgentsChatViewHost.startSession releases the model and cancels once deactivated while it loads, and opens no composer once deactivated while it looks the session up. - An explicit open of a tab that the chat part still restores is opened; restore()'s own opens stay restored. - The session a composer's first send made shows with reason 'commit', until the user opens or picks its tab again; a chat opened with IChatViewHostOpenOptions.restore shows as restored. - Removes a blank line at the end of agentsChatPart.ts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentsView/agentsChatPart.ts | 42 +++++++++++---- .../agentsView/agentsChatPartContribution.ts | 12 +++-- .../browser/agentsView/agentsChatViewHost.ts | 7 ++- .../browser/agentsView/agentsChatPart.test.ts | 53 +++++++++++++++++++ .../agentsView/agentsChatViewHost.test.ts | 48 +++++++++++++++-- .../editorWindowChatPartService.test.ts | 17 ++++++ 6 files changed, 160 insertions(+), 19 deletions(-) diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts index 94506e58dc2bf9..6567a4fe3e2cf9 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts @@ -8,7 +8,7 @@ import { raceTimeout } from '../../../../../base/common/async.js'; import { onUnexpectedError } from '../../../../../base/common/errors.js'; import { Event } from '../../../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { constObservable, derived, IObservable, observableFromEvent, observableValue, transaction, waitForState } from '../../../../../base/common/observable.js'; +import { constObservable, derived, IObservable, ITransaction, observableFromEvent, observableValue, transaction, waitForState } from '../../../../../base/common/observable.js'; import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -69,6 +69,8 @@ export interface IAgentsChatPartOpenOptions { readonly sideBySide?: boolean; /** Opens a new composer, also while the active tab is one that has not sent its session yet. */ readonly newComposer?: boolean; + /** Shows the chat as one the chat part restores, not one the user opens (see `IChatViewHostOpenOptions.restore`). */ + readonly restore?: boolean; } export const IAgentsChatPartService = createDecorator('agentsChatPartService'); @@ -200,8 +202,11 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart private readonly _activeReason = observableValue(this, 'open'); /** The tabs that {@link restore} shows, while it does: a chat the user opens meanwhile is opened, not restored. */ private readonly _restoringTabs = new Set(); - /** The tabs that are, or were, new-session composers: the session such a tab shows is the one its first send created. */ - private readonly _composerTabs = new WeakSet(); + /** + * The tabs of new-session composers, until the user opens or picks one once it shows the session its first send created: + * that session shows as committed, not opened. + */ + private readonly _composerTabs = observableValue>(this, new Set()); readonly shownSession: IObservable; private readonly _composerFolderProviders: (() => IAgentsChatPartComposerFolderOptions)[] = []; readonly activeChat: IObservable; @@ -244,8 +249,9 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart if (!input || !active) { return undefined; } - // A composer shows a session only from its first send on, which the user did, also in a tab the chat part restored - const reason = this._composerTabs.has(input) ? 'open' : this._activeReason.read(reader); + // A composer shows a session only from its first send on, which made it that session, also in a tab the chat part + // restored + const reason = this._composerTabs.read(reader).has(input) ? 'commit' : this._activeReason.read(reader); return { resource: active.chat.resource, reason }; }); // The window's host, as its service, shows the active tab's chat @@ -473,11 +479,24 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart this._layoutService.setPartHidden(false, Parts.CHAT_PART); const group = this._groupOf(chat.input) ?? (options?.sideBySide && !part.activeGroup.isEmpty ? part.addGroup(part.activeGroup, GroupDirection.RIGHT) : part.activeGroup); + // An explicit open of a tab that the chat part still restores is the user's: it is opened, not restored + this._restoringTabs.delete(chat.input); await this._editorService.openEditor(chat.input, { pinned: true, preserveFocus: options?.preserveFocus }, group); this.composers.didOpen([chat.chat.get()]); - // Opened, also a tab it restored and shows already, such as one the user picks in a sessions list + // Opened, also a tab it restored and shows already, such as one the user picks in a sessions list, unless shown as restored if (this._activeInput.get() === chat.input) { - this._activeReason.set('open', undefined); + transaction(tx => { + this._activeReason.set(options?.restore ? 'restore' : 'open', tx); + this._settleComposerTab(chat.input, tx); + }); + } + } + + /** A composer's tab that the user opens or picks once it shows its session is a chat's: it shows as opened, not committed. */ + private _settleComposerTab(input: EditorInput, tx: ITransaction): void { + const tabs = this._composerTabs.get(); + if (tabs.has(input) && this._chats.get(input)?.shown.get()) { + this._composerTabs.set(new Set([...tabs].filter(tab => tab !== input)), tx); } } @@ -498,7 +517,7 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart const chat = this._instantiationService.createInstance(AgentsChatPartChat, resource, composer, constObservable(this._window), this._activeInput, (chat: AgentsChatPartChat) => this._closeChat(chat)); this._chats.set(chat.input, chat); if (composer) { - this._composerTabs.add(chat.input); + this._composerTabs.set(new Set([...this._composerTabs.get(), chat.input]), undefined); } // It stays in the chat part: its editor shows there chat.input.setMoveRestriction({ @@ -508,6 +527,9 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart // Its chat goes with its editor, once the editor's dispose is done: disposing the chat disposes the editor, and what // it shows, which would otherwise happen within the editor's own dispose, such as as its session is removed meanwhile Event.once(chat.input.onWillDispose)(() => { + if (this._composerTabs.get().has(chat.input)) { + this._composerTabs.set(new Set([...this._composerTabs.get()].filter(tab => tab !== chat.input)), undefined); + } const ending = this._chats.deleteAndLeak(chat.input); if (ending) { queueMicrotask(() => ending.dispose()); @@ -564,6 +586,9 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart transaction(tx => { this._activeReason.set(active && this._restoringTabs.has(active) ? 'restore' : 'open', tx); this._activeInput.set(active, tx); + if (active) { + this._settleComposerTab(active, tx); + } }); } }; @@ -623,4 +648,3 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart } } - diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContribution.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContribution.ts index 162d993db9b4d6..cb987376706675 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContribution.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContribution.ts @@ -64,8 +64,9 @@ export class EditorWindowChatPartService extends Disposable implements IEditorWi declare readonly _serviceBrand: undefined; - private readonly _owners = observableValue(this, []); - readonly enabled: IObservable = derived(this, reader => this._owners.read(reader).some(owner => owner.enabled.read(reader))); + /** One per call of {@link enable}, also of the same options: each is removed by its own disposal only. */ + private readonly _owners = observableValue(this, []); + readonly enabled: IObservable = derived(this, reader => this._owners.read(reader).some(owner => owner.options.enabled.read(reader))); /** What the chat part does while a feature enabled it. */ private readonly _active = this._register(new MutableDisposable()); @@ -84,11 +85,12 @@ export class EditorWindowChatPartService extends Disposable implements IEditorWi } enable(options: IEditorWindowChatPartOptions): IDisposable { - this._owners.set([...this._owners.get(), options], undefined); + const owner = { options }; + this._owners.set([...this._owners.get(), owner], undefined); this._active.value ??= this._activate(); this._updateAnchor(); return toDisposable(() => { - this._owners.set(this._owners.get().filter(candidate => candidate !== options), undefined); + this._owners.set(this._owners.get().filter(candidate => candidate !== owner), undefined); if (!this._owners.get().length) { this._active.clear(); } else { @@ -98,7 +100,7 @@ export class EditorWindowChatPartService extends Disposable implements IEditorWi } private _updateAnchor(): void { - const anchor = [...this._owners.get()].sort((a, b) => b.anchorPriority - a.anchorPriority).at(0); + const anchor = this._owners.get().map(owner => owner.options).sort((a, b) => b.anchorPriority - a.anchorPriority).at(0); this._layoutService.setChatPartAnchor(anchor && { viewId: anchor.anchorViewId }); } diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts index 45e768ed8fda0e..b4c0141ef7277b 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts @@ -125,7 +125,7 @@ export class AgentsChatViewHost implements IChatViewHost { if (!owner || generation !== this._generation) { return undefined; } - await this._chatPart.openChat(owner.session, owner.chat.resource, { preserveFocus: options?.preserveFocus }); + await this._chatPart.openChat(owner.session, owner.chat.resource, { preserveFocus: options?.preserveFocus, restore: options?.restore }); return await this._waitForWidget(owner.chat.resource); } finally { hold.dispose(); @@ -161,6 +161,11 @@ export class AgentsChatViewHost implements IChatViewHost { if (!model) { throw new Error(`Unable to load the chat session ${sessionResource.toString()}`); } + if (generation !== this._generation) { + // Deactivated while the model loaded: it holds it no longer, and starts nothing + model.dispose(); + throw new CancellationError(); + } const sessionType = sessionResource.scheme; let composer: Promise | undefined; return { diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts index 1d5d20fa3462e7..653d3fd0dd16a0 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts @@ -308,6 +308,40 @@ suite('AgentsChatPartService', () => { assert.deepStrictEqual({ draft, untitled, firstTurn: active() }, { draft: 'none', untitled: 'none', firstTurn: { session: true, chat: true } }); }); + test('the session a composer\'s first send made shows as committed, until the user picks its tab again', async () => { + const status = observableValue('status', SessionStatus.Untitled); + const mainChat = upcastPartial({ resource: chatA }); + const created = upcastPartial({ status, mainChat: constObservable(mainChat) }); + const newSession = observableValue('newSession', undefined); + const { service, part } = createService({ newSession }); + const shown = () => { const value = service.shownSession.get(); return value ? `${value.resource.path} ${value.reason}` : 'none'; }; + + await service.openComposer(undefined); + const composerTab = part.activeGroup.activeEditor!; + const draft = shown(); + newSession.set(created, undefined); + status.set(SessionStatus.InProgress, undefined); + const committed = shown(); + // The user moves to another tab, and back to the composer's + await service.openComposer(undefined, { newComposer: true }); + part.activeGroup.open(composerTab); + + assert.deepStrictEqual({ draft, committed, picked: shown() }, { draft: 'none', committed: '/a commit', picked: '/a open' }); + }); + + test('a chat opened as restored shows as restored, and as opened once the user opens it', async () => { + const restoredSession = buildTestSession({ id: 'restored', title: 'Restored' }); + const main = restoredSession.mainChat.chat.resource; + const { service } = createService({ sessions: () => [restoredSession.session] }); + const shown = async () => { await timeout(0); const value = service.shownSession.get(); return value && `${isEqual(value.resource, main) ? 'main' : value.resource.toString()} ${value.reason}`; }; + + await service.openChat(restoredSession.session, main, { restore: true }); + const restored = await shown(); + await service.openChat(restoredSession.session, main); + + assert.deepStrictEqual({ restored, opened: await shown() }, { restored: 'main restore', opened: 'main open' }); + }); + test('closing the tab of a composer releases it: opening and closing composers keeps nothing', async () => { const { service, part, state } = createService(); const baseline = state().sessionListeners; @@ -568,6 +602,25 @@ suite('AgentsChatPartService', () => { assert.deepStrictEqual({ restored, reopened, opened: await shown() }, { restored: 'peer restore', reopened: 'peer open', opened: 'main open' }); }); + test('a chat the user opens while the chat part restores is opened, and the tab the restore activates is restored', async () => { + const shownSession = buildTestSession({ id: 'overlap', title: 'Overlap', chats: [{ id: 'peer', title: 'Peer' }] }); + const main = shownSession.mainChat.chat.resource; + const peer = shownSession.chats.get('peer')!.chat.resource; + const { service } = createService({ stored: JSON.stringify({ chats: [main.toString(), peer.toString()], active: 1 }), visible: true, sessions: () => [shownSession.session] }); + const shown = () => { const value = service.shownSession.get(); return value && `${isEqual(value.resource, main) ? 'main' : isEqual(value.resource, peer) ? 'peer' : value.resource.toString()} ${value.reason}`; }; + + const restoring = service.restore(); + // The user opens the restored tab that the restore activates, while it is under way + await service.openChat(shownSession.session, peer); + await restoring; + await timeout(0); + const afterRestore = shown(); + await service.openChat(shownSession.session, main); + await timeout(0); + + assert.deepStrictEqual({ afterRestore, afterOpen: shown() }, { afterRestore: 'peer open', afterOpen: 'main open' }); + }); + test('its composers offer the window\'s folders, or those a feature provides while it does', () => { const { service } = createService(); const folders = () => service.getComposerFolderOptions().allowedFolders?.get().map(folder => folder.toString()); diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts index 65eded26605b95..ac093452b134e3 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts @@ -201,7 +201,23 @@ suite('AgentsChatViewHost', () => { }); }); - test('deactivated while it loads the session it starts, it shows neither the session nor a composer', async () => { + test('opens a session as restored when asked to, so that it does not report it as opened', async () => { + const chatResource = URI.parse('agent-host-copilot:/session-1'); + const { owner, widget } = sessionOf(chatResource); + const opened: (boolean | undefined)[] = []; + const { host } = createHost({ + chatPart: { openChat: async (_session, _chat, openOptions) => { opened.push(openOptions?.restore); } }, + sessionsManagementService: { getSessionForChatResource: () => owner }, + widgets: [widget], + }); + + await host.openSession(chatResource, { restore: true }); + await host.openSession(chatResource); + + assert.deepStrictEqual(opened, [true, undefined]); + }); + + test('deactivated while it loads the session it starts, it releases the session and starts nothing', async () => { const load = new DeferredPromise(); const calls: string[] = []; const { host, calls: hostCalls } = createHost({ chatService: { acquireOrLoadSession: async () => { await load.p; return upcastPartial({ dispose: () => calls.push('release model') }); } } }); @@ -209,12 +225,36 @@ suite('AgentsChatViewHost', () => { const starting = host.startSession(URI.parse('agent-host-copilot:/untitled-1'), { local: false, importingConversation: false }); host.deactivate(); load.complete(); - const start = await starting; - const shown = await start.complete(URI.parse('agent-host-copilot:/session-1'), true); + let cancelled = false; + try { + await starting; + } catch (error) { + cancelled = isCancellationError(error); + } + + assert.deepStrictEqual({ cancelled, calls: [...hostCalls, ...calls] }, { cancelled: true, calls: ['release model'] }); + }); + + test('deactivated while it looks up the session it started, it shows neither the session nor a composer', async () => { + const lookup = new DeferredPromise(); + const chatResource = URI.parse('agent-host-copilot:/session-1'); + const { owner } = sessionOf(chatResource); + const { host, calls } = createHost({ + sessionsManagementService: { + getSessionForChatResource: () => lookup.isSettled ? owner : undefined, + onDidChangeSession: listener => { void lookup.p.then(() => listener([])); return toDisposable(() => { }); }, + }, + }); + + const start = await host.startSession(URI.parse('agent-host-copilot:/untitled-1'), { local: false, importingConversation: false }); + const completing = start.complete(chatResource, true); + host.deactivate(); + lookup.complete(); + const shown = await completing; const composer = await start.createComposer(); start.dispose(); - assert.deepStrictEqual({ shown, composer, calls: [...hostCalls, ...calls] }, { shown: false, composer: undefined, calls: ['release model'] }); + assert.deepStrictEqual({ shown, composer, calls }, { shown: false, composer: undefined, calls: ['hold agent-host-copilot:/untitled-1', 'acquire', 'release', 'release agent-host-copilot:/untitled-1'] }); }); test('deactivated while it looks a session up, it does not open the chat part', async () => { diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/editorWindowChatPartService.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/editorWindowChatPartService.test.ts index 8fa7881640db9f..0dc87c68c45087 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/editorWindowChatPartService.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/editorWindowChatPartService.test.ts @@ -118,6 +118,23 @@ suite('EditorWindowChatPartService', () => { ]); }); + test('a feature that enables it twice with the same options keeps it until it disposes both', async () => { + const { service, events } = createService({ visible: false }); + const options = { anchorViewId: 'list', anchorPriority: 1, enabled: constObservable(true) }; + + const first = service.enable(options); + const second = service.enable(options); + first.dispose(); + const afterFirst = { enabled: service.enabled.get(), events: [...events] }; + second.dispose(); + await timeout(0); + + assert.deepStrictEqual({ afterFirst, afterBoth: service.enabled.get() }, { + afterFirst: { enabled: true, events: ['create part', 'hold providers', 'register composer holder', 'register host', 'register presenter', 'anchor list', 'anchor list', 'anchor list'] }, + afterBoth: false, + }); + }); + test('restores the chats it showed as the window last closed, while chats open in it', async () => { const { service, events } = createService({ visible: true }); From 9482af2a9812edb18169a327e7137434ae4dc243 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:52:45 +0100 Subject: [PATCH 042/165] Editor window sessions: let a feature override the chat the window's session host shows EditorWindowMainSessionHost.overrideShownChat shows the chat that a feature names in place of the bound one until disposed, such as the Chat view's chat while chats open there and not in the chat part (as #348309's sessions in the Chat view use it). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentsView/editorWindowMainSessionHost.ts | 18 ++++++++++++++++- .../test/browser/agentsViewServices.test.ts | 20 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowMainSessionHost.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowMainSessionHost.ts index e5f876c9f139d1..136b05171767ea 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowMainSessionHost.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowMainSessionHost.ts @@ -25,10 +25,12 @@ export interface IEditorWindowShownChat { export class EditorWindowMainSessionHost extends SessionHost { private readonly _shown: ISettableObservable | undefined>; + private readonly _override: ISettableObservable | undefined>; constructor(@IContextKeyService contextKeyService: IContextKeyService) { const source = observableValue | undefined>('editorWindowShownChat', undefined); - const shown = derived(reader => source.read(reader)?.read(reader)); + const override = observableValue | undefined>('editorWindowShownChatOverride', undefined); + const shown = derived(reader => (override.read(reader) ?? source.read(reader))?.read(reader)); super({ kind: SessionHostKind.MainWindow, parent: undefined, @@ -38,6 +40,20 @@ export class EditorWindowMainSessionHost extends SessionHost { windowId: constObservable(mainWindow.vscodeWindowId), }); this._shown = source; + this._override = override; + } + + /** + * Shows the chat `shown` names in place of the one bound with {@link bindShownChat}, until the returned disposable is + * disposed: such as the chat of the Chat view while the chats open there, and not in the chat part. + */ + overrideShownChat(shown: IObservable): IDisposable { + this._override.set(shown, undefined); + return toDisposable(() => { + if (this._override.get() === shown) { + this._override.set(undefined, undefined); + } + }); } /** Shows the chat `shown` names, until the returned disposable is disposed. */ diff --git a/src/vs/workbench/contrib/sessions/providers/agentsView/test/browser/agentsViewServices.test.ts b/src/vs/workbench/contrib/sessions/providers/agentsView/test/browser/agentsViewServices.test.ts index e6e9d04d833b65..705a2e8bd1cd7e 100644 --- a/src/vs/workbench/contrib/sessions/providers/agentsView/test/browser/agentsViewServices.test.ts +++ b/src/vs/workbench/contrib/sessions/providers/agentsView/test/browser/agentsViewServices.test.ts @@ -9,6 +9,9 @@ import { getSingletonServiceDescriptors } from '../../../../../../../platform/in import { _util, ServiceIdentifier } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { IContextKeyService } from '../../../../../../../platform/contextkey/common/contextkey.js'; import { EditorWindowMainSessionHost } from '../../../../browser/agentsView/editorWindowMainSessionHost.js'; +import { constObservable } from '../../../../../../../base/common/observable.js'; +import { upcastPartial } from '../../../../../../../base/test/common/mock.js'; +import { IChat, ISession, SessionStatus } from '../../../../../../services/sessions/common/session.js'; import { ChatView, NewChatView } from '../../../../browser/chat/chatView.js'; import { NewChatInSessionWidget } from '../../../../browser/chat/newChatInSessionWidget.js'; import { NewChatWidget } from '../../../../browser/chat/newChatWidget.js'; @@ -104,4 +107,21 @@ suite('Editor window sessions services', () => { assert.deepStrictEqual({ used, content: host.content.get(), session: host.session.get(), chat: host.chat.get() }, { used: [], content: { kind: 'empty' }, session: undefined, chat: undefined }); }); + + test('the session host of an editor window shows the chat an override names in place of the bound one, until the override ends', () => { + const contextKeyService = upcastPartial({}); + const host = new EditorWindowMainSessionHost(contextKeyService); + const chat = (id: string) => ({ session: upcastPartial({ sessionId: id, status: constObservable(SessionStatus.Completed), mainChat: constObservable(upcastPartial({})) }), chat: upcastPartial({}) }); + const shownId = () => host.session.get()?.sessionId; + + const bound = host.bindShownChat(constObservable(chat('chat part'))); + const beforeOverride = shownId(); + const override = host.overrideShownChat(constObservable(chat('chat view'))); + const overridden = shownId(); + override.dispose(); + const afterOverride = shownId(); + bound.dispose(); + + assert.deepStrictEqual({ beforeOverride, overridden, afterOverride, afterBound: shownId() }, { beforeOverride: 'chat part', overridden: 'chat view', afterOverride: 'chat part', afterBound: undefined }); + }); }); From eb1c5194b09a02c836607cf230c7c0f7fb2e8730 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:53:59 +0100 Subject: [PATCH 043/165] Worktree infra: wait for the local agent host sessions to resolve, also once another provider resolved Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostWorktreeLocations.ts | 26 +++++++-- .../agentHostWorktreeLocations.test.ts | 57 ++++++++++++++++--- 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts index 904f53daca7fa6..90ab5600fb0c4b 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts @@ -6,6 +6,7 @@ import { raceCancellation } from '../../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { isAbsolute } from '../../../../../../base/common/path.js'; import { basename, dirname, extUriBiasedIgnorePathCase, joinPath, normalizePath } from '../../../../../../base/common/resources.js'; @@ -15,7 +16,7 @@ import { FileOperationResult, IFileService, toFileOperationResult } from '../../ import { ILogService } from '../../../../../../platform/log/common/log.js'; import { IFolderLocation, IWorkspaceFolderLocationProvider } from '../../../../../../platform/workspace/common/folderLocations.js'; import { readFolderGitCheckout } from '../../../../../services/workspaces/common/gitFolderCheckout.js'; -import { isLocalAgentHostTarget } from '../../../common/chatSessionsService.js'; +import { IChatSessionsService, isLocalAgentHostTarget } from '../../../common/chatSessionsService.js'; import { IAgentSession } from '../agentSessionsModel.js'; import { IAgentSessionsService } from '../agentSessionsService.js'; @@ -49,25 +50,38 @@ export function getAgentHostSessionWorkingDirectories(sessions: readonly IAgentS * window's repository (see `WorkspaceFolderRepositories`), and when that repository's entry for it names its `.git` back. * That keeps a folder that only points into the repository from inheriting the primary's trust. */ -export class AgentHostWorktreeLocationProvider implements IWorkspaceFolderLocationProvider { +export class AgentHostWorktreeLocationProvider extends Disposable implements IWorkspaceFolderLocationProvider { readonly id = AGENT_HOST_WORKTREE_LOCATIONS_PROVIDER_ID; + /** The local agent host providers whose sessions the sessions list has resolved. */ + private readonly _resolvedProviders = new Set(); + constructor( @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, + @IChatSessionsService private readonly _chatSessionsService: IChatSessionsService, @IFileService private readonly _fileService: IFileService, @ILogService private readonly _logService: ILogService, - ) { } + ) { + super(); + this._register(_agentSessionsService.model.onDidResolve(provider => { + if (isLocalAgentHostTarget(provider)) { + this._resolvedProviders.add(provider); + } + })); + } async provideLocations(primary: URI, token: CancellationToken): Promise { const worktreesDir = await this._worktreesDir(primary); if (!worktreesDir || token.isCancellationRequested) { return []; } - // Before the sessions list first resolves, it has only the sessions it cached: wait for the ones it has not + // Before the sessions list first resolves a local agent host provider, it has only the sessions it cached of it: + // wait for that provider, also while others have resolved, and leave the others alone const model = this._agentSessionsService.model; - if (!model.resolved) { - await raceCancellation(model.resolve(undefined, undefined, 'automatic'), token); + const unresolved = this._chatSessionsService.getRegisteredChatSessionItemProviders().filter(provider => isLocalAgentHostTarget(provider) && !this._resolvedProviders.has(provider)); + if (unresolved.length) { + await raceCancellation(model.resolve(unresolved, undefined, 'automatic'), token); if (token.isCancellationRequested) { return []; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts index 0c1af172077e39..6bb8d11ce4a5c5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts @@ -4,8 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { DeferredPromise } from '../../../../../../../base/common/async.js'; import { VSBuffer } from '../../../../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../../../../base/common/cancellation.js'; +import { Emitter } from '../../../../../../../base/common/event.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; @@ -15,7 +17,7 @@ import { ILogService, NullLogService } from '../../../../../../../platform/log/c import { AgentHostWorktreeLocationProvider, getAgentHostSessionWorkingDirectories } from '../../../../browser/agentSessions/agentHost/agentHostWorktreeLocations.js'; import { IAgentSession } from '../../../../browser/agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../../../../browser/agentSessions/agentSessionsService.js'; -import { SessionType } from '../../../../common/chatSessionsService.js'; +import { IChatSessionsService, SessionType } from '../../../../common/chatSessionsService.js'; suite('AgentHostWorktreeLocations', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -28,7 +30,11 @@ suite('AgentHostWorktreeLocations', () => { * A provider over git entries: `true` is a `.git` directory, and a string the content of a file, such as a worktree's * `.git` file or its entry's `gitdir` file in the repository. */ - function createProvider(entries: Record, sessions: readonly IAgentSession[], uncached: readonly IAgentSession[] = [], links: Record = {}) { + /** + * @param uncached the sessions of the local agent host provider that the sessions list has only once that provider resolves, + * which `loading` holds, while another provider, `remote`, has resolved. + */ + function createProvider(entries: Record, sessions: readonly IAgentSession[], uncached: readonly IAgentSession[] = [], links: Record = {}, loading?: Promise) { const fileService = upcastPartial({ stat: async (resource: URI) => { const entry = entries[resource.path]; @@ -53,10 +59,28 @@ suite('AgentHostWorktreeLocations', () => { const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(IFileService, fileService); instantiationService.stub(ILogService, new NullLogService()); - // The sessions it cached, and those the list has only once it resolved - const model = { sessions: [...sessions], resolved: !uncached.length, resolve: async () => { model.sessions.push(...uncached); model.resolved = true; } }; + // The sessions it cached, and those the list has only once the local agent host provider resolved + const onDidResolve = store.add(new Emitter()); + const resolving: string[] = []; + const model = { + sessions: [...sessions], + resolved: false, + onDidResolve: onDidResolve.event, + resolve: async (providers: string[]) => { + resolving.push(...providers); + await loading; + model.sessions.push(...uncached); + model.resolved = true; + providers.forEach(provider => onDidResolve.fire(provider)); + }, + }; instantiationService.stub(IAgentSessionsService, upcastPartial({ model: upcastPartial(model) })); - return instantiationService.createInstance(AgentHostWorktreeLocationProvider); + instantiationService.stub(IChatSessionsService, upcastPartial({ getRegisteredChatSessionItemProviders: () => ['remote', SessionType.AgentHostCopilot] })); + const provider = store.add(instantiationService.createInstance(AgentHostWorktreeLocationProvider)); + // Another provider resolved first + model.resolved = true; + onDidResolve.fire('remote'); + return Object.assign(provider, { resolving }); } const repository = { @@ -123,10 +147,25 @@ suite('AgentHostWorktreeLocations', () => { }); }); - test('waits for the sessions list to resolve before reading the working directories of sessions it did not cache', async () => { - const provider = createProvider(repository, [session(SessionType.AgentHostCopilot, '/work/vscode.worktrees/feature')], [session(SessionType.AgentHostCopilot, '/work/vscode.worktrees/relative')]); - const locations = await provider.provideLocations(URI.file('/work/vscode'), CancellationToken.None); - assert.deepStrictEqual(locations.map(location => location.label), ['feature', 'relative']); + test('waits for the local agent host sessions to resolve, also once another provider resolved, before reading the working directories of sessions it did not cache', async () => { + const loading = new DeferredPromise(); + const provider = createProvider(repository, [session(SessionType.AgentHostCopilot, '/work/vscode.worktrees/feature')], [session(SessionType.AgentHostCopilot, '/work/vscode.worktrees/relative')], {}, loading.p); + let locations: string[] | undefined; + const providing = provider.provideLocations(URI.file('/work/vscode'), CancellationToken.None).then(result => locations = result.map(location => location.label)); + await new Promise(resolve => setTimeout(resolve, 0)); + const whileLoading = locations; + await loading.complete(); + await providing; + const resolvingBefore = [...provider.resolving]; + // Once resolved, it reads the sessions as the list has them + await provider.provideLocations(URI.file('/work/vscode'), CancellationToken.None); + + assert.deepStrictEqual({ whileLoading, locations, resolving: resolvingBefore, again: provider.resolving }, { + whileLoading: undefined, + locations: ['feature', 'relative'], + resolving: [SessionType.AgentHostCopilot], + again: [SessionType.AgentHostCopilot], + }); }); test('a worktree is described by its branch, the short commit of a detached HEAD, or nothing without HEAD', async () => { From 75ccadb53eb593e95ae60efc92ea05d0a1c96784 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:56:10 +0100 Subject: [PATCH 044/165] Worktree support: dispose the location provider with the contribution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentSessions/agentHost/agentHostWorktree.contribution.ts | 2 +- .../browser/agentSessions/agentHost/agentHostWorktree.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts index b2b107e51a606c..2ca84c1cc07875 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts @@ -137,7 +137,7 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, ) { super(); - this._provider = instantiationService.createInstance(AgentHostWorktreeLocationProvider); + this._provider = this._register(instantiationService.createInstance(AgentHostWorktreeLocationProvider)); this._lastSessions = this._readLastSessions(); if (environmentService.isSessionsWindow) { return; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts index 6e43c91ed70d28..85054f41b9437b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts @@ -88,7 +88,7 @@ suite('AgentHostWorktree', () => { const path = workingDirectories.get(resource.toString()); return path ? upcastPartial({ metadata: { workingDirectoryPath: path } }) : undefined; }, - model: upcastPartial({ onDidChangeSessions: onDidChangeSessions.event }), + model: upcastPartial({ onDidChangeSessions: onDidChangeSessions.event, onDidResolve: Event.None }), })); instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IContextKeyService, contextKeyService); From 914a7f30ceab51edb5c57c2ca42b908e4f9e507a Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 05:57:21 +0100 Subject: [PATCH 045/165] Chat part anchor: ignore an unregistered view's saved location, restore the exact focus after a move, and test the listeners through events Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/workbench/browser/layout.ts | 37 ++++++--- src/vs/workbench/test/browser/layout.test.ts | 84 ++++++++++++++++++-- 2 files changed, 100 insertions(+), 21 deletions(-) diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index f835e902b249b6..4da7cd41e20f9d 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -7,7 +7,7 @@ import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } import { Event, Emitter } from '../../base/common/event.js'; import { onUnexpectedError } from '../../base/common/errors.js'; import { alert } from '../../base/browser/ui/aria/aria.js'; -import { EventType, addDisposableListener, getClientArea, size, IDimension, isAncestorUsingFlowTo, computeScreenAwareSize, getActiveDocument, getWindows, getActiveWindow, isActiveDocument, getWindow, getWindowId, getActiveElement, Dimension } from '../../base/browser/dom.js'; +import { EventType, addDisposableListener, getClientArea, size, IDimension, isAncestorUsingFlowTo, computeScreenAwareSize, getActiveDocument, getWindows, getActiveWindow, isActiveDocument, getWindow, getWindowId, getActiveElement, Dimension, isHTMLElement } from '../../base/browser/dom.js'; import { onDidChangeFullscreen, isFullscreen, isWCOEnabled } from '../../base/browser/browser.js'; import { isWindows, isLinux, isMacintosh, isWeb, isIOS } from '../../base/common/platform.js'; import { EditorInputCapabilities, GroupIdentifier, isResourceEditorInput, IUntypedEditorInput, pathsToEditors } from '../common/editor.js'; @@ -1806,15 +1806,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi })); this._register(this.storageService.onWillSaveState(() => this.saveChatPartLayout())); - // The chat part follows its anchor view as the view, its container or its location changes - this._register(Event.any( - this.viewDescriptorService.onDidChangeContainerLocation, - this.viewDescriptorService.onDidChangeContainer, - this.viewDescriptorService.onDidChangeLocation, - this.viewDescriptorService.onDidChangeViewContainers, - Registry.as(ViewExtensions.ViewsRegistry).onViewsRegistered, - Registry.as(ViewExtensions.ViewsRegistry).onViewsDeregistered - )(() => this.updateChatPartPosition())); + this._register(this.followChatPartAnchor()); } // The floating cards abut the top window edge only while neither of the two grid rows @@ -2528,13 +2520,26 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi return this.chatPartPosition ?? this.computeChatPartPosition(); } + /** Has the chat part follow its anchor view as the view, its container or its location changes. */ + private followChatPartAnchor(): IDisposable { + return Event.any( + this.viewDescriptorService.onDidChangeContainerLocation, + this.viewDescriptorService.onDidChangeContainer, + this.viewDescriptorService.onDidChangeLocation, + this.viewDescriptorService.onDidChangeViewContainers, + Registry.as(ViewExtensions.ViewsRegistry).onViewsRegistered, + Registry.as(ViewExtensions.ViewsRegistry).onViewsDeregistered + )(() => this.updateChatPartPosition()); + } + /** * The side of the editor the chat part belongs on: the side of its anchor view's container. A view in a panel at the * top or bottom, an unknown view, or no anchor take the primary side bar's side. */ private computeChatPartPosition(): Position.LEFT | Position.RIGHT { const sideBarPosition = this.getSideBarPosition() === Position.RIGHT ? Position.RIGHT : Position.LEFT; - const location = this.chatPartAnchor ? this.viewDescriptorService.getViewLocationById(this.chatPartAnchor.viewId) : null; + // A view that is not registered has no location, also where a move of it was saved + const location = this.chatPartAnchor && this.viewDescriptorService.getViewDescriptorById(this.chatPartAnchor.viewId) ? this.viewDescriptorService.getViewLocationById(this.chatPartAnchor.viewId) : null; switch (location) { case ViewContainerLocation.AuxiliaryBar: return sideBarPosition === Position.LEFT ? Position.RIGHT : Position.LEFT; @@ -2575,8 +2580,10 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.chatPartPosition = this.computeChatPartPosition(); - // Moving a view in the grid takes it out of the DOM and back: what had focus in it gets it again + // Moving a view in the grid takes it out of the DOM and back: what had focus in it gets it again, such as a link of + // the transcript or a button of its toolbar, else the chat part's default const hadFocus = this.hasFocus(Parts.CHAT_PART); + const focused = hadFocus ? getActiveElement() : null; const visible = !this.chatPartHidden; const width = visible ? this.workbenchGrid.getViewSize(this.chatPartView).width : undefined; const sizing = width ?? Sizing.Invisible(this.workbenchGrid.getViewCachedVisibleSize(this.chatPartView) ?? this.chatPartView.minimumWidth); @@ -2585,7 +2592,11 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.workbenchGrid.resizeView(this.chatPartView, { width, height: this.workbenchGrid.getViewSize(this.chatPartView).height }); } if (hadFocus) { - this.focusPart(Parts.CHAT_PART); + if (isHTMLElement(focused) && focused.isConnected && this.chatPartView.element.contains(focused)) { + focused.focus(); + } else { + this.focusPart(Parts.CHAT_PART); + } } } diff --git a/src/vs/workbench/test/browser/layout.test.ts b/src/vs/workbench/test/browser/layout.test.ts index b7a43cbaca1dc9..426f37f419a0d0 100644 --- a/src/vs/workbench/test/browser/layout.test.ts +++ b/src/vs/workbench/test/browser/layout.test.ts @@ -7,15 +7,16 @@ import assert from 'assert'; import { $, append } from '../../../base/browser/dom.js'; import { Grid, Direction, ISerializedGrid, ISerializedNode, Orientation, SerializableGrid } from '../../../base/browser/ui/grid/grid.js'; import { mainWindow } from '../../../base/browser/window.js'; -import { Event } from '../../../base/common/event.js'; +import { Emitter, Event } from '../../../base/common/event.js'; import { toDisposable } from '../../../base/common/lifecycle.js'; -import { mock } from '../../../base/test/common/mock.js'; +import { mock, upcastPartial } from '../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; import { Layout } from '../../browser/layout.js'; import { Part, updateSashEndpoints } from '../../browser/part.js'; import { PanelAlignment, Parts, Position } from '../../services/layout/browser/layoutService.js'; import { NullTelemetryService } from '../../../platform/telemetry/common/telemetryUtils.js'; -import { ViewContainerLocation } from '../../common/views.js'; +import { Registry } from '../../../platform/registry/common/platform.js'; +import { Extensions as ViewExtensions, IViewDescriptor, IViewsRegistry, ViewContainer, ViewContainerLocation } from '../../common/views.js'; suite('Workbench layout sash endpoints', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -148,6 +149,8 @@ suite('Workbench layout chat part', () => { readonly chat?: { readonly visible: boolean; readonly size: number } | 'noLayout'; /** The location of the view the chat part docks beside; `null` for a view that is not known; no anchor without. */ readonly anchor?: ViewContainerLocation | null; + /** Whether that view is registered, as it is by default; one that is not keeps a location saved for it. */ + readonly anchorRegistered?: boolean; } /** The state a window left, to start another window from, as after a reload. */ @@ -195,7 +198,8 @@ suite('Workbench layout chat part', () => { ['auxiliaryBar.hidden', false], ]); const initialization = new Map([['sideBar.size', 250], ['auxiliaryBar.size', 260], ['panel.size', 200]]); - const anchor = { location: configuration.anchor ?? null }; + const anchor = { location: configuration.anchor ?? null, registered: configuration.anchorRegistered ?? true }; + const viewEvents = { location: store.add(new Emitter()), container: store.add(new Emitter()), containerLocation: store.add(new Emitter()), containers: store.add(new Emitter()) }; let stored = saved ? saved.stored : configuration.chat && configuration.chat !== 'noLayout' ? JSON.stringify({ version: 1, ...configuration.chat }) : undefined; const focus = { chat: false, focused: [] as string[] }; const layout = store.add(new class extends Layout { @@ -213,7 +217,15 @@ suite('Workbench layout chat part', () => { telemetryService: NullTelemetryService, // No view containers: the side bar and the panel show and hide empty paneCompositeService: { getActivePaneComposite: () => undefined, getLastActivePaneCompositeId: () => undefined }, - viewDescriptorService: { getViewContainersByLocation: () => [], getViewLocationById: () => anchor.location }, + viewDescriptorService: { + getViewContainersByLocation: () => [], + getViewDescriptorById: () => anchor.registered ? {} : null, + getViewLocationById: () => anchor.location, + onDidChangeLocation: viewEvents.location.event, + onDidChangeContainer: viewEvents.container.event, + onDidChangeContainerLocation: viewEvents.containerLocation.event, + onDidChangeViewContainers: viewEvents.containers.event, + }, configurationService: { getValue: () => undefined }, storageService: { get: () => stored, store: (_key: string, value: string) => stored = value }, stateModel: { @@ -230,7 +242,7 @@ suite('Workbench layout chat part', () => { (Reflect.get(layout, 'saveChatPartLayout') as () => void).call(layout); return { runtime, stored }; }; - return { layout, views, descriptor, save, anchor, focus }; + return { layout, views, descriptor, save, anchor, focus, viewEvents }; } /** The parts of `node`, nested as the grid nests them, marking hidden parts. */ @@ -306,13 +318,13 @@ suite('Workbench layout chat part', () => { }); function createGridLayout(configuration: ILayoutConfiguration, saved?: ISavedLayout) { - const { layout, views, descriptor, save, anchor, focus } = createLayout(configuration, saved); + const { layout, views, descriptor, save, anchor, focus, viewEvents } = createLayout(configuration, saved); const grid = store.add(SerializableGrid.deserialize(descriptor, { fromJSON: ({ type }: { type: Parts }) => views.get(type)! }, { proportionalLayout: false })); append(layout.mainContainer, grid.element); grid.layout(1600, 900); Object.assign(layout, { workbenchGrid: grid }); const middle = () => shape((grid.serialize().root.data as ISerializedNode[])[2]); - return { layout, grid, middle, save, anchor, views, focus }; + return { layout, grid, middle, save, anchor, views, focus, viewEvents }; } test('the visible neighbors of the parts beside a chat part are the chat part, as long as it shows', () => { @@ -435,6 +447,7 @@ suite('Workbench layout chat part', () => { rightPanel: middle({ sideBar: Position.LEFT, panel: Position.RIGHT, anchor: ViewContainerLocation.Panel }), bottomPanel: middle({ sideBar: Position.RIGHT, panel: Position.BOTTOM, anchor: ViewContainerLocation.Panel }), unknownView: middle({ sideBar: Position.LEFT, panel: Position.BOTTOM, anchor: null }), + unregisteredViewWithSavedMove: middle({ sideBar: Position.LEFT, panel: Position.BOTTOM, anchor: ViewContainerLocation.AuxiliaryBar, anchorRegistered: false }), }, { sideBar: ['activitybar', 'sidebar', [['chat', 'editor'], 'panel'], 'auxiliarybar'], sideBarRight: ['auxiliarybar', [['editor', 'chat'], 'panel'], 'sidebar', 'activitybar'], @@ -444,6 +457,7 @@ suite('Workbench layout chat part', () => { rightPanel: ['activitybar', 'sidebar', 'editor', 'chat', 'panel', 'auxiliarybar'], bottomPanel: ['auxiliarybar', [['editor', 'chat'], 'panel'], 'sidebar', 'activitybar'], unknownView: ['activitybar', 'sidebar', [['chat', 'editor'], 'panel'], 'auxiliarybar'], + unregisteredViewWithSavedMove: ['activitybar', 'sidebar', [['chat', 'editor'], 'panel'], 'auxiliarybar'], }); }); @@ -532,4 +546,58 @@ suite('Workbench layout chat part', () => { deregistered: ['activitybar', 'sidebar', [['chat', 'editor'], 'panel'], 'auxiliarybar'], }); }); + + test('follows its anchor view through the events of the views as they move, deregister and register again', () => { + const { layout, middle, anchor, viewEvents } = createGridLayout({ sideBar: Position.LEFT, panel: Position.BOTTOM, alignment: 'center', chat: { visible: true, size: 400 }, anchor: ViewContainerLocation.Sidebar }); + store.add((Reflect.get(layout, 'followChatPartAnchor') as () => { dispose(): void }).call(layout)); + const viewsRegistry = Registry.as(ViewExtensions.ViewsRegistry); + const container = upcastPartial({ id: 'test.chatPartAnchor' }); + const view = upcastPartial({ id: 'sessions', name: { value: 'Sessions', original: 'Sessions' } }); + const steps: Record = {}; + + anchor.location = ViewContainerLocation.AuxiliaryBar; + viewEvents.location.fire(); + steps.viewMoved = middle(); + anchor.location = ViewContainerLocation.Sidebar; + viewEvents.containerLocation.fire(); + steps.containerMoved = middle(); + viewsRegistry.registerViews([view], container); + anchor.location = ViewContainerLocation.AuxiliaryBar; + viewEvents.container.fire(); + steps.viewContainerChanged = middle(); + anchor.registered = false; + viewsRegistry.deregisterViews([view], container); + steps.deregistered = middle(); + anchor.registered = true; + viewsRegistry.registerViews([view], container); + steps.registeredAgain = middle(); + viewsRegistry.deregisterViews([view], container); + + assert.deepStrictEqual(steps, { + viewMoved: ['activitybar', 'sidebar', [['editor', 'chat'], 'panel'], 'auxiliarybar'], + containerMoved: ['activitybar', 'sidebar', [['chat', 'editor'], 'panel'], 'auxiliarybar'], + viewContainerChanged: ['activitybar', 'sidebar', [['editor', 'chat'], 'panel'], 'auxiliarybar'], + deregistered: ['activitybar', 'sidebar', [['chat', 'editor'], 'panel'], 'auxiliarybar'], + registeredAgain: ['activitybar', 'sidebar', [['editor', 'chat'], 'panel'], 'auxiliarybar'], + }); + }); + + test('the chat part gives the focus back to what had it inside, such as a button of its toolbar, as it moves to its anchor view\'s side', () => { + const { layout, middle, anchor, focus, views } = createGridLayout({ sideBar: Position.LEFT, panel: Position.BOTTOM, alignment: 'center', chat: { visible: true, size: 400 }, anchor: ViewContainerLocation.Sidebar }); + mainWindow.document.body.appendChild(layout.mainContainer); + store.add(toDisposable(() => layout.mainContainer.remove())); + const button = append(views.get(Parts.CHAT_PART)!.element, $('button')); + button.focus(); + focus.chat = true; + const focusedBefore = mainWindow.document.activeElement === button; + anchor.location = ViewContainerLocation.AuxiliaryBar; + (Reflect.get(layout, 'updateChatPartPosition') as () => void).call(layout); + + assert.deepStrictEqual({ focusedBefore, moved: middle(), focusedAfter: mainWindow.document.activeElement === button, defaultFocus: focus.focused }, { + focusedBefore: true, + moved: ['activitybar', 'sidebar', [['editor', 'chat'], 'panel'], 'auxiliarybar'], + focusedAfter: true, + defaultFocus: [], + }); + }); }); From dd36a4e47688ee63f5c1a0ee4a4a44d0ff7f7654 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 06:05:07 +0100 Subject: [PATCH 046/165] Chat part: composers follow their host to another folder, and each explicit open is reported again - ChatWindowComposerModel.retargetFolder / onDidRetarget, with the folder its host aimed it at: a composer still aimed at that folder follows its host to another one, such as where the window's folder moved; not one the user picked, nor while it sends (from the banner, with its tests). The new-session widget creates its draft for the new target. - IAgentsChatPartService.retargetComposerFolders(to, from) retargets every composer aimed at `from`; a new session that reuses the active composer aims it at its folder. - Each explicit open of the active chat reports it again (as #348304's Chat view does), as where its session works may matter again. - Merges #348302 8b26ed9d94b and #348304 eb1c5194b09. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentsView/agentsChatPart.ts | 25 ++++++++++ .../sessions/browser/chat/newChatWidget.ts | 8 +++ .../browser/chatEditor/chatWindowComposer.ts | 27 ++++++++++ .../sessions/browser/parts/chatView.ts | 5 ++ .../browser/agentsView/agentsChatPart.test.ts | 43 +++++++++++++++- .../chatEditor/chatWindowComposer.test.ts | 49 +++++++++++++++++++ 6 files changed, 155 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts index 6567a4fe3e2cf9..08e91e485ac092 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts @@ -135,6 +135,12 @@ export interface IAgentsChatPartService { * disposable is disposed. The provider set last applies. */ setComposerFolderOptions(provider: () => IAgentsChatPartComposerFolderOptions): IDisposable; + + /** + * Aims its composers that aim at `from`, the folder their host aimed them at, at `to`, such as the location the window's + * folder moved to: not those aiming at a folder the user picked, nor those that send (see `ChatWindowComposerModel.retargetFolder`). + */ + retargetComposerFolders(to: URI, from: URI): void; } /** The folders the composers of the chat part create sessions in, besides the folder each aims at, and how they show them. */ @@ -200,6 +206,8 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart private readonly _activeInput = observableValue(this, undefined); /** Why the active tab became active: as {@link restore} showed it, or else as the user opened or picked it. */ private readonly _activeReason = observableValue(this, 'open'); + /** Increases with each explicit open of the active tab: each is reported again, as where its session works may matter again. */ + private readonly _explicitOpens = observableValue(this, 0); /** The tabs that {@link restore} shows, while it does: a chat the user opens meanwhile is opened, not restored. */ private readonly _restoringTabs = new Set(); /** @@ -209,6 +217,8 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart private readonly _composerTabs = observableValue>(this, new Set()); readonly shownSession: IObservable; private readonly _composerFolderProviders: (() => IAgentsChatPartComposerFolderOptions)[] = []; + /** The composer of each composer's tab, which the tab disposes. */ + private readonly _composerModels = new Map(); readonly activeChat: IObservable; private _part: IContentPart | undefined; @@ -251,6 +261,7 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart } // A composer shows a session only from its first send on, which made it that session, also in a tab the chat part // restored + this._explicitOpens.read(reader); const reason = this._composerTabs.read(reader).has(input) ? 'commit' : this._activeReason.read(reader); return { resource: active.chat.resource, reason }; }); @@ -296,6 +307,12 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart /** The folders of the window, the only ones its composers create sessions in by default, besides the folder each aims at. */ private readonly _windowFolders: IObservable; + retargetComposerFolders(to: URI, from: URI): void { + for (const composer of this._composerModels.values()) { + composer.retargetFolder(to, from); + } + } + getComposerFolderOptions(): IAgentsChatPartComposerFolderOptions { return this._composerFolderProviders.at(-1)?.() ?? { allowedFolders: this._windowFolders }; } @@ -328,6 +345,11 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart const active = this._activeInput.get(); const activeChat = active && this._chats.get(active); if (activeChat?.isComposer && !options?.sideBySide && !options?.newComposer) { + // It aims at the new session's folder, such as where the window's folder is now, unless the user picked another + const composer = active && this._composerModels.get(active); + if (composer && target?.folderUri) { + composer.retargetFolder(target.folderUri); + } return this._open(activeChat, options); } const resource = createChatWindowComposerResource(); @@ -488,6 +510,7 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart transaction(tx => { this._activeReason.set(options?.restore ? 'restore' : 'open', tx); this._settleComposerTab(chat.input, tx); + this._explicitOpens.set(this._explicitOpens.get() + 1, tx); }); } } @@ -518,6 +541,7 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart this._chats.set(chat.input, chat); if (composer) { this._composerTabs.set(new Set([...this._composerTabs.get(), chat.input]), undefined); + this._composerModels.set(chat.input, composer); } // It stays in the chat part: its editor shows there chat.input.setMoveRestriction({ @@ -527,6 +551,7 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart // Its chat goes with its editor, once the editor's dispose is done: disposing the chat disposes the editor, and what // it shows, which would otherwise happen within the editor's own dispose, such as as its session is removed meanwhile Event.once(chat.input.onWillDispose)(() => { + this._composerModels.delete(chat.input); if (this._composerTabs.get().has(chat.input)) { this._composerTabs.set(new Set([...this._composerTabs.get()].filter(tab => tab !== chat.input)), undefined); } diff --git a/src/vs/workbench/contrib/sessions/browser/chat/newChatWidget.ts b/src/vs/workbench/contrib/sessions/browser/chat/newChatWidget.ts index 9e0051db6dedaa..57ab9ff5a274ef 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/newChatWidget.ts +++ b/src/vs/workbench/contrib/sessions/browser/chat/newChatWidget.ts @@ -794,6 +794,14 @@ export class NewChatWidget extends Disposable { } if (this._draftHost) { this._register(this._reseedAfterFailedSend(this._draftHost)); + // Its host aims it at another folder, such as where the window's folder moved: its draft is created there + if (this._draftHost.onDidRetarget) { + this._register(this._draftHost.onDidRetarget(target => { + if (!isSessionCreated(this._session.get())) { + this._seedRequestedTarget(target); + } + })); + } } // Comment 2: Re-evaluate the tip when the selected model changes, because diff --git a/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts b/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts index b2d6dd50dc3b06..0b5e5c2a3ba165 100644 --- a/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts +++ b/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts @@ -230,6 +230,13 @@ export class ChatWindowComposerModel extends Disposable implements INewSessionDr private readonly _onWillDispose = this._register(new Emitter()); readonly onWillDispose = this._onWillDispose.event; + private readonly _onDidRetarget = this._register(new Emitter()); + /** Fires as its host aims it at another folder ({@link retargetFolder}), for which it creates its draft. */ + readonly onDidRetarget = this._onDidRetarget.event; + + /** The folder its host aimed it at: while it aims at that one, the user did not pick another. */ + private _hostFolder: URI | undefined; + /** The custom group of the sessions list that the sessions it sends join. */ readonly groupId: string | undefined; @@ -254,6 +261,7 @@ export class ChatWindowComposerModel extends Disposable implements INewSessionDr return folders.length && targetFolder && !folders.some(folder => extUriBiasedIgnorePathCase.isEqual(folder, targetFolder)) ? [...folders, targetFolder] : folders; }); this._requestedTarget.set(init.target, undefined); + this._hostFolder = init.target?.folderUri; this.input = new ComposerInputSnapshot(init.input); this.slot = this._register(_sessionsManagementService.createNewSessionSlot({ readoptOnFailure: session => this._isRequested(session) })); this._register(sessionGroupsService.setDraftSlotGroup(this.slot.id, init.groupId)); @@ -295,6 +303,25 @@ export class ChatWindowComposerModel extends Disposable implements INewSessionDr } } + /** + * Aims it at `folderUri`, such as the location the window's folder moved to, while it aims at the folder its host + * aimed it at (`from`, when given): not at one the user picked, and not while it sends: from the user's submit, through + * the checks and preparation before its request is accepted, until that request ends. Returns whether it does. + */ + retargetFolder(folderUri: URI, from?: URI): boolean { + const target = this._requestedTarget.get(); + const current = target?.folderUri; + if (this.input.get().attempt || this._sending.get() || !target || !current || !this._hostFolder || !extUriBiasedIgnorePathCase.isEqual(current, this._hostFolder) + || (from && !extUriBiasedIgnorePathCase.isEqual(current, from)) || extUriBiasedIgnorePathCase.isEqual(current, folderUri)) { + return false; + } + const retargeted = { ...target, folderUri }; + this._hostFolder = folderUri; + this._requestedTarget.set(retargeted, undefined); + this._onDidRetarget.fire(retargeted); + return true; + } + getFolderPresentation(folderUri: URI): IFolderLocation | undefined { return this._getFolderPresentation?.(folderUri); } diff --git a/src/vs/workbench/contrib/sessions/browser/parts/chatView.ts b/src/vs/workbench/contrib/sessions/browser/parts/chatView.ts index 25df1c4f65a922..a4098a487f89e9 100644 --- a/src/vs/workbench/contrib/sessions/browser/parts/chatView.ts +++ b/src/vs/workbench/contrib/sessions/browser/parts/chatView.ts @@ -116,6 +116,11 @@ export interface INewSessionDraftHost { * offers no other. Unset, it offers any workspace. */ readonly allowedFolders?: IObservable; + /** + * Fires as the host aims the composer at another folder, such as the location the window's folder moved to: the composer + * creates its draft for it, unless it sent its session already. + */ + readonly onDidRetarget?: Event; /** How its workspace picker shows one of its {@link allowedFolders}, such as a linked checkout of a window's folder. */ getFolderPresentation?(folderUri: URI): IFolderLocation | undefined; /** Opens a session the composer started in the background, in the composer's own window. */ diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts index 653d3fd0dd16a0..2aa62c86d32dcd 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { DisposableStore, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; -import { constObservable, IObservable, observableValue, transaction } from '../../../../../../base/common/observable.js'; +import { autorun, constObservable, IObservable, observableValue, transaction } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -15,6 +15,7 @@ import { TestConfigurationService } from '../../../../../../platform/configurati import { IContextKeyChangeEvent, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IEditorOptions } from '../../../../../../platform/editor/common/editor.js'; import { MockContextKeyService } from '../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { parse } from '../../../../../../base/common/marshalling.js'; import { IStorageService, StorageScope, StorageTarget, WillSaveStateReason } from '../../../../../../platform/storage/common/storage.js'; import { EditorInputWithOptions } from '../../../../../common/editor.js'; import { EditorInput } from '../../../../../common/editor/editorInput.js'; @@ -32,7 +33,7 @@ import { AgentsChatPartService, IAgentsChatPartService, restoreAgentsChatPart } import { IAgentsChatPartFrame } from '../../../browser/agentsView/agentsChatPartFrame.js'; import { EditorWindowMainSessionHost } from '../../../browser/agentsView/editorWindowMainSessionHost.js'; import { ChatEditorInput } from '../../../browser/chatEditor/chatEditor.js'; -import { isChatWindowComposerResource } from '../../../browser/chatEditor/chatWindowComposer.js'; +import { CHAT_WINDOW_COMPOSERS_STORAGE_KEY, isChatWindowComposerResource } from '../../../browser/chatEditor/chatWindowComposer.js'; import { IChatViewFactory } from '../../../browser/chatViewFactory.js'; import { ISessionGroupsService } from '../../../browser/sessionGroupsService.js'; import { INewSessionDraftSlot, ISessionChange, ISessionsManagementService } from '../../../common/sessionsManagement.js'; @@ -253,6 +254,12 @@ suite('AgentsChatPartService', () => { storageService.testEmitWillSaveState(WillSaveStateReason.NONE); return storageService.get(CHAT_PART_EDITORS_STORAGE_KEY, StorageScope.WORKSPACE); }, + /** The folders the chat part's composers aim at, as they store them. */ + composerFolders: () => { + storageService.testEmitWillSaveState(WillSaveStateReason.NONE); + const raw = storageService.get(CHAT_WINDOW_COMPOSERS_STORAGE_KEY, StorageScope.WORKSPACE); + return raw ? (parse(raw) as { readonly target?: { readonly folderUri?: URI } }[]).map(composer => composer.target?.folderUri?.path) : []; + }, setShowTabs: (value: string) => { configurationService.setUserConfiguration('workbench.editor.showTabs', value); configurationService.onDidChangeConfigurationEmitter.fire(upcastPartial({ affectsConfiguration: (key: string) => key === 'workbench.editor.showTabs' })); @@ -329,6 +336,22 @@ suite('AgentsChatPartService', () => { assert.deepStrictEqual({ draft, committed, picked: shown() }, { draft: 'none', committed: '/a commit', picked: '/a open' }); }); + test('each explicit open of the active chat reports it again, as where its session works may matter again', async () => { + const openedSession = buildTestSession({ id: 'reopened', title: 'Reopened' }); + const main = openedSession.mainChat.chat.resource; + const { service } = createService({ sessions: () => [openedSession.session] }); + const reports: string[] = []; + const listener = autorun(reader => { const value = service.shownSession.read(reader); if (value) { reports.push(value.reason); } }); + + await service.openChat(openedSession.session, main); + await timeout(0); + await service.openChat(openedSession.session, main); + await timeout(0); + listener.dispose(); + + assert.deepStrictEqual(reports.slice(-2), ['open', 'open']); + }); + test('a chat opened as restored shows as restored, and as opened once the user opens it', async () => { const restoredSession = buildTestSession({ id: 'restored', title: 'Restored' }); const main = restoredSession.mainChat.chat.resource; @@ -342,6 +365,22 @@ suite('AgentsChatPartService', () => { assert.deepStrictEqual({ restored, opened: await shown() }, { restored: 'main restore', opened: 'main open' }); }); + test('its composers that aim at the folder their host aimed them at follow it to another folder, also the active one a new session reuses', async () => { + const { service, composerFolders } = createService(); + + await service.openComposer({ folderUri: URI.file('/repo') }); + await service.openComposer({ folderUri: URI.file('/other') }, { newComposer: true }); + service.retargetComposerFolders(URI.file('/repo.worktrees/a'), URI.file('/repo')); + const afterMove = composerFolders(); + // A new session in the active composer, which has not sent its session, aims it at the new session's folder + await service.openComposer({ folderUri: URI.file('/repo.worktrees/b') }); + + assert.deepStrictEqual({ afterMove, afterReuse: composerFolders() }, { + afterMove: ['/repo.worktrees/a', '/other'], + afterReuse: ['/repo.worktrees/a', '/repo.worktrees/b'], + }); + }); + test('closing the tab of a composer releases it: opening and closing composers keeps nothing', async () => { const { service, part, state } = createService(); const baseline = state().sessionListeners; diff --git a/src/vs/workbench/contrib/sessions/test/browser/chatEditor/chatWindowComposer.test.ts b/src/vs/workbench/contrib/sessions/test/browser/chatEditor/chatWindowComposer.test.ts index 69190b7218dc3b..1198ebf4aec826 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/chatEditor/chatWindowComposer.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/chatEditor/chatWindowComposer.test.ts @@ -492,6 +492,55 @@ suite('Chat window composer', () => { }); }); + test('a composer that aims at the folder its host aimed it at follows its host to another folder, such as where the window\'s folder moved; not once the user picked another folder, nor from a folder it does not aim at', () => { + const store = disposables.add(new DisposableStore()); + const { instantiationService } = createServices(store); + const composers = store.add(instantiationService.createInstance(ChatWindowComposers, async () => undefined)); + const composer = store.add(composers.create(URI.parse('vscode-agents-composer:/retarget'), { target: { folderUri: URI.file('/repo'), providerId: 'provider' } }, async () => { })); + const retargets: string[] = []; + store.add(composer.onDidRetarget(target => retargets.push(`${target.folderUri?.path} ${target.providerId}`))); + const aimsAt = () => composer.requestedTarget.get()?.folderUri?.path; + + const moved = { done: composer.retargetFolder(URI.file('/repo.worktrees/a'), URI.file('/repo')), aimsAt: aimsAt() }; + const fromElsewhere = { done: composer.retargetFolder(URI.file('/repo.worktrees/b'), URI.file('/repo')), aimsAt: aimsAt() }; + composer.setRequestedTarget({ folderUri: URI.file('/repo.worktrees/c') }, 'user'); + const userPicked = { done: composer.retargetFolder(URI.file('/repo')), aimsAt: aimsAt() }; + + assert.deepStrictEqual({ moved, fromElsewhere, userPicked, retargets }, { + moved: { done: true, aimsAt: '/repo.worktrees/a' }, + fromElsewhere: { done: false, aimsAt: '/repo.worktrees/a' }, + userPicked: { done: false, aimsAt: '/repo.worktrees/c' }, + retargets: ['/repo.worktrees/a provider'], + }); + }); + + test('a composer does not follow its host to another folder while it sends: from the user\'s submit, before its request is accepted, until the request ends', () => { + const store = disposables.add(new DisposableStore()); + const { onDidAccept, onDidEnd, slots, createComposer } = createServices(store); + const composer = store.add(createComposer()); + const { slot } = slots[0]; + const aimsAt = () => composer.requestedTarget.get()?.folderUri?.path; + const retarget = (folder: string, from: string) => ({ done: composer.retargetFolder(URI.file(folder), URI.file(from)), aimsAt: aimsAt() }); + + // Submitted: its checks and the draft's preparation are under way, its request not accepted yet + const attempt = composer.input.beginSendAttempt({ inputText: 'hello', attachments: [] }); + const beforeAccepted = retarget('/repo.worktrees/a', '/repo'); + // Accepted: its request is under way + const draft = createSession('draft', { folder: '/repo' }); + onDidAccept.fire({ session: draft, owner: slot, background: false }); + const accepted = retarget('/repo.worktrees/a', '/repo'); + // It did not start its session: the composer shows its draft again and follows its host again + onDidEnd.fire({ session: draft, owner: slot, started: false, readopted: false }); + composer.input.endSendAttempt(attempt); + const afterEnded = retarget('/repo.worktrees/a', '/repo'); + + assert.deepStrictEqual({ beforeAccepted, accepted, afterEnded }, { + beforeAccepted: { done: false, aimsAt: '/repo' }, + accepted: { done: false, aimsAt: '/repo' }, + afterEnded: { done: true, aimsAt: '/repo.worktrees/a' }, + }); + }); + test('the composers of chat windows are stored as the state is saved, not as their input changes, with what they aim for, their group, their input and a send that did not end; they restore as a composer with that input or with what the send sent, or as the session a send started; a discarded composer and a malformed entry are not stored', async () => { const store = disposables.add(new DisposableStore()); const started = createSession('started', { folder: '/repo', status: constObservable(SessionStatus.Completed) }); From fa880971e20a0f6389532eef9a144c358e0a5f08 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 06:10:18 +0100 Subject: [PATCH 047/165] Chat part: actions only while chats open in it, New Chat ownership, and the sessions-only state of the Chat view's list - The chat part registers its actions only while chats open in it, not as soon as a feature enables it. - IEditorWindowChatPartOptions.ownsNewChat (default true): the chat part's view host takes New Chat of the Chat view only while a feature that has chats open in it takes it; one that sets it to false, such as the sessions of the plain Chat view, leaves New Chat to the Chat view. - IChatViewSessionsListService.sessionsOnly reports whether the Chat view shows only the sessions of the provided list it shows (setActiveList, which the Chat view calls for the list it creates). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../widgetHosts/viewPane/chatViewPane.ts | 1 + .../viewPane/chatViewSessionsList.ts | 20 +++++++- .../viewPane/chatViewSessionsList.test.ts | 31 ++++++++++++ .../agentsView/agentsChatPartContribution.ts | 13 +++-- .../browser/agentsView/agentsChatViewHost.ts | 6 ++- .../agentsView/agentsChatViewHost.test.ts | 7 ++- .../editorWindowChatPartService.test.ts | 48 +++++++++++++++++-- 7 files changed, 114 insertions(+), 12 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewSessionsList.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index b09312e71d27f3..e3be6caf0132e7 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -985,6 +985,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { this.sessionsControlContainer = append(sessionsContainer, $('.agent-sessions-control-container')); const provided = this.providedSessionsList = this.sessionsListService.provider?.create(this.sessionsControlContainer, this.instantiationService, { overrideStyles: this.getLocationBasedColors().listOverrideStyles }); if (provided) { + this._register(this.sessionsListService.setActiveList(provided)); // A list of its own header and actions, in place of those of the Chat view's list setVisibility(!provided.hasHeader, sessionsTitleContainer); setVisibility(false, newSessionButtonContainer); diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewSessionsList.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewSessionsList.ts index 1585367d18fb37..b8075159f9ee3a 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewSessionsList.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewSessionsList.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; -import { IObservable } from '../../../../../../base/common/observable.js'; +import { derived, IObservable, observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { InstantiationType, registerSingleton } from '../../../../../../platform/instantiation/common/extensions.js'; import { createDecorator, IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -65,6 +65,12 @@ export interface IChatViewSessionsListService { readonly _serviceBrand: undefined; readonly provider: IChatViewSessionsListProvider | undefined; registerProvider(provider: IChatViewSessionsListProvider): IDisposable; + + /** Whether the Chat view shows only the sessions of the list it shows, without its chat: false while it shows no provided list. */ + readonly sessionsOnly: IObservable; + + /** Called by the Chat view with the provided list it shows, until the returned disposable is disposed. */ + setActiveList(list: IChatViewSessionsList): IDisposable; } export class ChatViewSessionsListService implements IChatViewSessionsListService { @@ -78,6 +84,18 @@ export class ChatViewSessionsListService implements IChatViewSessionsListService return this._providers.at(-1)?.provider; } + private readonly _activeList = observableValue(this, undefined); + readonly sessionsOnly: IObservable = derived(this, reader => this._activeList.read(reader)?.sessionsOnly.read(reader) ?? false); + + setActiveList(list: IChatViewSessionsList): IDisposable { + this._activeList.set(list, undefined); + return toDisposable(() => { + if (this._activeList.get() === list) { + this._activeList.set(undefined, undefined); + } + }); + } + registerProvider(provider: IChatViewSessionsListProvider): IDisposable { const registration = { provider }; this._providers.push(registration); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewSessionsList.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewSessionsList.test.ts new file mode 100644 index 00000000000000..5ce871fdd9a5d6 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewSessionsList.test.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { observableValue } from '../../../../../../../base/common/observable.js'; +import { upcastPartial } from '../../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { ChatViewSessionsListService, IChatViewSessionsList } from '../../../../browser/widgetHosts/viewPane/chatViewSessionsList.js'; + +suite('ChatViewSessionsListService', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('reports whether the Chat view shows only the sessions of the provided list it shows, and not once it no longer shows that list', () => { + const service = new ChatViewSessionsListService(); + const sessionsOnly = observableValue('sessionsOnly', true); + const steps: boolean[] = [service.sessionsOnly.get()]; + + const active = service.setActiveList(upcastPartial({ sessionsOnly })); + steps.push(service.sessionsOnly.get()); + sessionsOnly.set(false, undefined); + steps.push(service.sessionsOnly.get()); + sessionsOnly.set(true, undefined); + active.dispose(); + steps.push(service.sessionsOnly.get()); + + assert.deepStrictEqual(steps, [false, true, false, false]); + }); +}); diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContribution.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContribution.ts index cb987376706675..1888122b516185 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContribution.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContribution.ts @@ -38,6 +38,12 @@ export interface IEditorWindowChatPartOptions { * its chats, and the chat part does not restore and hides, as the window gets ready and as this turns false. */ readonly enabled: IObservable; + /** + * Whether New Chat of the Chat view opens a new-session composer in the chat part while chats open in it; otherwise the + * Chat view keeps its own New Chat. True by default. While several features enable the chat part, it does while one of + * those that have it enabled says so. + */ + readonly ownsNewChat?: boolean; } export const IEditorWindowChatPartService = createDecorator('editorWindowChatPartService'); @@ -67,6 +73,7 @@ export class EditorWindowChatPartService extends Disposable implements IEditorWi /** One per call of {@link enable}, also of the same options: each is removed by its own disposal only. */ private readonly _owners = observableValue(this, []); readonly enabled: IObservable = derived(this, reader => this._owners.read(reader).some(owner => owner.options.enabled.read(reader))); + private readonly _ownsNewChat: IObservable = derived(this, reader => this._owners.read(reader).some(owner => owner.options.enabled.read(reader) && owner.options.ownsNewChat !== false)); /** What the chat part does while a feature enabled it. */ private readonly _active = this._register(new MutableDisposable()); @@ -111,8 +118,6 @@ export class EditorWindowChatPartService extends Disposable implements IEditorWi // The part, before the workbench creates its grid: reading it creates the delayed frame void this._frame.part; - store.add(registerAgentsChatPartActions()); - // The Changes editor that the pills of the chats open in the editor area, as the Agents window registers it store.add(Registry.as(EditorExtensions.EditorPane).registerEditorPane( EditorPaneDescriptor.create(SessionChangesEditor, SessionChangesEditor.ID, localize('sessionChangesEditor.label', "Changes")), @@ -145,7 +150,9 @@ export class EditorWindowChatPartService extends Disposable implements IEditorWi if (!this.enabled.read(reader)) { return; } - const host = this._instantiationService.createInstance(AgentsChatViewHost); + // Its actions, such as to close it, only while chats open in it + reader.store.add(registerAgentsChatPartActions()); + const host = this._instantiationService.createInstance(AgentsChatViewHost, this._ownsNewChat); reader.store.add(this._chatViewHostService.registerHost(host)); // Unregistered, what the host is still doing does not open the chat part reader.store.add(toDisposable(() => host.deactivate())); diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts index b4c0141ef7277b..7b7408bbef175d 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts @@ -41,6 +41,8 @@ const WAIT_TIMEOUT = 5_000; export class AgentsChatViewHost implements IChatViewHost { constructor( + /** Whether New Chat of the Chat view opens a composer here; otherwise the Chat view keeps it (see {@link newChat}). */ + private readonly _ownsNewChat: IObservable, @IAgentsChatPartService private readonly _chatPart: IAgentsChatPartService, @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, @@ -86,9 +88,9 @@ export class AgentsChatViewHost implements IChatViewHost { return type !== localChatSessionType && this._sessionsManagementService.getAllSessionTypes().some(sessionType => (sessionType.chatSessionType ?? sessionType.id) === type); } - /** New Chat, such as + in the title of the Chat view, opens a new-session composer in the chat part. */ + /** New Chat, such as + in the title of the Chat view, opens a new-session composer in the chat part, unless the Chat view keeps it. */ newChat(): boolean { - if (!this._available) { + if (!this._ownsNewChat.get() || !this._available) { return false; } this._chatPart.openComposer(this._composerTarget(), { newComposer: true }).catch(onUnexpectedError); diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts index ac093452b134e3..5ea563f2d1617e 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts @@ -39,6 +39,7 @@ suite('AgentsChatViewHost', () => { readonly providersAcquired?: () => void; readonly contentCreated?: IObservable; readonly chatService?: Partial; + readonly ownsNewChat?: boolean; } function createHost(options: IHostOptions = {}) { @@ -54,6 +55,7 @@ suite('AgentsChatViewHost', () => { ...options.chatPart, }); const host = new AgentsChatViewHost( + constObservable(options.ownsNewChat ?? true), chatPart, upcastPartial({ getSessionForChatResource: () => undefined, getSession: () => undefined, getAllSessionTypes: () => [], getSessions: () => [], onDidChangeSession: () => toDisposable(() => { }), ...options.sessionsManagementService }), upcastPartial({ getAllWidgets: () => options.widgets ?? [] }), @@ -297,16 +299,19 @@ suite('AgentsChatViewHost', () => { }, { agentHost: true, knownChat: true, knownSession: true, untitledOfOfferedType: true, local: false, extension: false }); }); - test('New Chat opens a new-session composer in the chat part, unless chat is hidden or disabled', () => { + test('New Chat opens a new-session composer in the chat part, unless chat is hidden or disabled, or the Chat view keeps its New Chat', () => { const available = createHost(); const unavailable = createHost({ chatAvailable: false }); + const notOwned = createHost({ ownsNewChat: false }); assert.deepStrictEqual({ available: available.host.newChat(), availableCalls: available.calls, unavailable: unavailable.host.newChat(), unavailableCalls: unavailable.calls, + notOwned: notOwned.host.newChat(), notOwnedCalls: notOwned.calls, }, { available: true, availableCalls: ['openComposer sessionType=undefined newComposer=true'], unavailable: false, unavailableCalls: [], + notOwned: false, notOwnedCalls: [], }); }); diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/editorWindowChatPartService.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/editorWindowChatPartService.test.ts index 0dc87c68c45087..755ddf7a5bdfe8 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/editorWindowChatPartService.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/editorWindowChatPartService.test.ts @@ -7,9 +7,10 @@ import assert from 'assert'; import { timeout } from '../../../../../../base/common/async.js'; import { Event } from '../../../../../../base/common/event.js'; import { toDisposable } from '../../../../../../base/common/lifecycle.js'; -import { constObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { constObservable, IObservable, observableValue } from '../../../../../../base/common/observable.js'; import { upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { CommandsRegistry } from '../../../../../../platform/commands/common/commands.js'; import { ContextKeyValue, IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchLayoutService, Parts } from '../../../../../services/layout/browser/layoutService.js'; @@ -29,6 +30,7 @@ suite('EditorWindowChatPartService', () => { /** The service, as the window starts with the chat part shown or not, recording what it does. */ function createService(state: { visible: boolean }) { const events: string[] = []; + const ownsNewChat: IObservable[] = []; const layoutService = upcastPartial({ setChatPartAnchor: anchor => events.push(`anchor ${anchor?.viewId}`), onDidChangePartVisibility: Event.None, @@ -46,9 +48,13 @@ suite('EditorWindowChatPartService', () => { }); const chatPart = upcastPartial({ restore: async () => { events.push('restore'); } }); const instantiationService = upcastPartial({ - createInstance: (ctor: unknown) => ctor === AgentsChatViewHost - ? upcastPartial({ deactivate: () => events.push('deactivate host') }) - : toDisposable(() => { }), + createInstance: (ctor: unknown, ...args: unknown[]) => { + if (ctor === AgentsChatViewHost) { + ownsNewChat.push(args[0] as IObservable); + return upcastPartial({ deactivate: () => events.push('deactivate host') }); + } + return toDisposable(() => { }); + }, invokeFunction: (fn: (accessor: ServicesAccessor, ...args: TS) => R, ...args: TS): R => fn(upcastPartial({ get: (): T => chatPart as T }), ...args), }); const service = disposables.add(new EditorWindowChatPartService( @@ -64,7 +70,7 @@ suite('EditorWindowChatPartService', () => { upcastPartial({ enable: () => { events.push('hold providers'); return toDisposable(() => events.push('release providers')); } }), upcastPartial({ get part() { events.push('create part'); return undefined!; }, contentCreated: constObservable(false) }), )); - return { service, events }; + return { service, events, ownsNewChat }; } test('does nothing until a feature enables it; features share the chat part, which docks beside the anchor of the highest priority', async () => { @@ -135,6 +141,38 @@ suite('EditorWindowChatPartService', () => { }); }); + test('registers its actions only while chats open in it', async () => { + const enabled = observableValue('enabled', false); + const { service } = createService({ visible: false }); + const registered = () => !!CommandsRegistry.getCommand('workbench.action.agentsView.focusChatPart'); + + const registration = service.enable({ anchorViewId: 'chat', anchorPriority: 1, enabled }); + const whileNot = registered(); + enabled.set(true, undefined); + const whileEnabled = registered(); + registration.dispose(); + await timeout(0); + + assert.deepStrictEqual({ whileNot, whileEnabled, afterDispose: registered() }, { whileNot: false, whileEnabled: true, afterDispose: false }); + }); + + test('takes New Chat of the Chat view while a feature that has chats open in it takes it, by default; not for a feature that leaves it to the Chat view', async () => { + const { service, ownsNewChat } = createService({ visible: false }); + const listEnabled = observableValue('listEnabled', true); + + const plain = service.enable({ anchorViewId: 'chat', anchorPriority: 1, enabled: constObservable(true), ownsNewChat: false }); + const onlyPlain = ownsNewChat.at(-1)!.get(); + const viewlet = service.enable({ anchorViewId: 'list', anchorPriority: 2, enabled: listEnabled }); + const withViewlet = ownsNewChat.at(-1)!.get(); + listEnabled.set(false, undefined); + const viewletOff = ownsNewChat.at(-1)!.get(); + viewlet.dispose(); + plain.dispose(); + await timeout(0); + + assert.deepStrictEqual({ onlyPlain, withViewlet, viewletOff }, { onlyPlain: false, withViewlet: true, viewletOff: false }); + }); + test('restores the chats it showed as the window last closed, while chats open in it', async () => { const { service, events } = createService({ visible: true }); From 6e0d90b8d7015ac62591297942d579f327298c43 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 06:11:55 +0100 Subject: [PATCH 048/165] chat: New Chat of a composer shown during a slow load supersedes the load Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../widgetHosts/viewPane/chatViewPane.ts | 5 ++- .../widgetHosts/viewPane/chatViewPane.test.ts | 32 ++++++++++++++++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index b09312e71d27f3..052354c30bb990 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -909,10 +909,13 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { if (!this.providedComposer) { return false; } - this.providedComposer.newChat(); if (!this.composerShown) { + this.providedComposer.newChat(); return false; } + // A chat still loading, which cleared the chat and so shows the composer, no longer replaces it + this.loadSessionCts.value?.cancel(); + this.providedComposer.newChat(); this.providedComposer.focus(); return true; } diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index 5dc05e44d886ff..53d4a502ec3d96 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -130,11 +130,12 @@ suite('ChatViewPane sessions refresh', () => { test('New Chat starts the provided composer over, and is the composer\'s while it shows in place of the chat', () => { const calls: string[] = []; const composer = { newChat: () => calls.push('newChat'), focus: () => calls.push('focus') }; - const newChat = Reflect.get(ChatViewPane.prototype, 'newChat') as (this: { providedComposer: typeof composer | undefined; composerShown: boolean }) => boolean; + const newChat = Reflect.get(ChatViewPane.prototype, 'newChat') as (this: { providedComposer: typeof composer | undefined; composerShown: boolean; loadSessionCts: { value: undefined } }) => boolean; + const loadSessionCts = { value: undefined }; const results = { - shown: newChat.call({ providedComposer: composer, composerShown: true }), - hidden: newChat.call({ providedComposer: composer, composerShown: false }), - withoutComposer: newChat.call({ providedComposer: undefined, composerShown: false }), + shown: newChat.call({ providedComposer: composer, composerShown: true, loadSessionCts }), + hidden: newChat.call({ providedComposer: composer, composerShown: false, loadSessionCts }), + withoutComposer: newChat.call({ providedComposer: undefined, composerShown: false, loadSessionCts }), }; assert.deepStrictEqual({ results, calls }, { results: { shown: true, hidden: false, withoutComposer: false }, calls: ['newChat', 'focus', 'newChat'] }); }); @@ -479,6 +480,29 @@ suite('ChatViewPane session transitions', () => { }); }); + test('New Chat of the composer that a slow load shows, as it cleared the chat, supersedes the load', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const rig = createPane(); + const acquiring = new DeferredPromise(); + const composerCalls: string[] = []; + Object.assign(rig.pane, { + chatService: { acquireOrLoadSession: () => acquiring.p }, + providedComposer: { newChat: () => composerCalls.push('newChat'), focus: () => composerCalls.push('focus') }, + }); + const loading = rig.pane.loadSession(nextResource.with({ path: '/saved' })); + await timeout(150); + // The cleared chat is a new chat: the composer shows in its place + Object.assign(rig.pane, { composerShown: true }); + const taken = (Reflect.get(ChatViewPane.prototype, 'newChat') as (this: object) => boolean).call(rig.pane); + await acquiring.complete(rig.nextRef); + await loading; + assert.deepStrictEqual({ taken, composerCalls, bindings: rig.bindings, disposed: rig.disposed }, { + taken: true, + composerCalls: ['newChat', 'focus'], + bindings: [undefined], + disposed: [previousResource, nextResource], + }); + })); + for (const untitled of [true, false]) { test(`${untitled ? 'new chats stay bound' : 'saved chats still clear'} while a slow load is pending`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { const rig = createPane(); From 92c4607f6239b03b2e165793072c7f995c295b8b Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 06:14:49 +0100 Subject: [PATCH 049/165] Worktree support: the chat part's new-session composers offer the folder's locations and follow the folder as it moves While worktree support and AI features are on, and once the chat part exists, its composers offer the window's folder, its own location and the worktrees sessions work in, presented as folder or worktree with the current one marked, and retarget as the folder moves. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostWorktreeComposerFolders.ts | 188 ++++++++++++++ .../agentHostWorktreeComposerFolders.test.ts | 242 ++++++++++++++++++ src/vs/workbench/workbench.common.main.ts | 1 + 3 files changed, 431 insertions(+) create mode 100644 src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts create mode 100644 src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts new file mode 100644 index 00000000000000..14b9eda7f758a8 --- /dev/null +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts @@ -0,0 +1,188 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { onUnexpectedError } from '../../../../../base/common/errors.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { Disposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { autorun, derived, IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { extUriBiasedIgnorePathCase } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { observableConfigValue, observableContextKey } from '../../../../../platform/observable/common/platformObservableUtils.js'; +import { IFolderLocation, IWorkspaceFolderLocationService } from '../../../../../platform/workspace/common/folderLocations.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; +import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; +import { ChatContextKeys } from '../../../chat/common/actions/chatContextKeys.js'; +import { ChatConfiguration } from '../../../chat/common/constants.js'; +import { IAllowedFolderPresentation } from '../chat/sessionWorkspacePicker.js'; +import { IAgentsChatPartService } from './agentsChatPart.js'; +import { IAgentsChatPartFrame } from './agentsChatPartFrame.js'; + +/** + * The folders the chat part's new-session composers create their sessions in: the window's folders and, for the one local + * folder of a window that can move, its other locations, such as its own location and the worktrees sessions work in. + */ +export class AgentHostWorktreeComposerFolders extends Disposable { + + private readonly _locations = observableValue(this, []); + private readonly _folders = observableValue(this, []); + readonly folders: IObservable = this._folders; + + /** Cancels the read of the locations under way, as another starts or this is disposed. */ + private readonly _refresh = this._register(new MutableDisposable()); + + private readonly _onDidMoveFolder = this._register(new Emitter<{ readonly from: URI; readonly to: URI }>()); + /** Fires as the window's folder moves from one of its locations to another, such as into a worktree. */ + readonly onDidMoveFolder = this._onDidMoveFolder.event; + /** Where the window's movable folder was, and its own location. */ + private _at: { readonly location: URI; readonly primary: URI } | undefined; + + constructor( + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IWorkspaceFolderLocationService private readonly _folderLocationService: IWorkspaceFolderLocationService, + ) { + super(); + this._register(_folderLocationService.onDidChangeProviders(() => this.refresh())); + // The location the folder is at shows as one also before or without its provider's list + this._register(_folderLocationService.onDidChangeLocation(() => this._update(this._locations.get()))); + // Also as the window's folders change, such as as the folder moves: the service tells whether it can move again then + this._register(_folderLocationService.onDidChangeCanSetLocation(() => this._onDidChangeFolders())); + this._at = this._location(); + this._update([]); + } + + private _onDidChangeFolders(): void { + const from = this._at; + const to = this._at = this._location(); + const samePrimary = !!from && !!to && extUriBiasedIgnorePathCase.isEqual(from.primary, to.primary); + // The location it moved to is offered at once, with the known locations, as they are read again + this._update(samePrimary ? this._locations.get() : []); + this.refresh(); + if (from && to && samePrimary && !extUriBiasedIgnorePathCase.isEqual(from.location, to.location)) { + this._onDidMoveFolder.fire({ from: from.location, to: to.location }); + } + } + + private _location(): { readonly location: URI; readonly primary: URI } | undefined { + const folder = this._movableFolder(); + const primary = this._workspaceContextService.getWorkspace().uri; + return folder && primary ? { location: folder.uri, primary } : undefined; + } + + /** + * How a composer shows `folderUri`: a location of the window's folder by its name and branch, and, for a window whose + * folder can move, whether it is the folder's own location or a worktree of it, and whether the folder is there now. + */ + getPresentation(folderUri: URI): IAllowedFolderPresentation | undefined { + const location = this._locations.get().find(candidate => extUriBiasedIgnorePathCase.isEqual(candidate.uri, folderUri)); + const folder = this._movableFolder(); + const workspace = this._workspaceContextService.getWorkspace(); + if (!folder || !workspace.uri) { + return location; + } + // The folder's own location, and any other folder of the window, show as folders; its other locations as worktrees + const isFolder = extUriBiasedIgnorePathCase.isEqual(folderUri, workspace.uri) + || workspace.folders.slice(1).some(windowFolder => extUriBiasedIgnorePathCase.isEqual(windowFolder.uri, folderUri)); + return { + ...location, + kind: isFolder ? 'folder' : 'worktree', + current: extUriBiasedIgnorePathCase.isEqual(folderUri, folder.uri), + }; + } + + /** Reads the locations of the window's folder again, such as a worktree an agent created since. */ + refresh(): void { + this._refresh.clear(); + const primary = this._movableFolder() && this._workspaceContextService.getWorkspace().uri; + if (!primary) { + this._update([]); + return; + } + const source = new CancellationTokenSource(); + this._refresh.value = toDisposable(() => source.dispose(true)); + this._folderLocationService.getLocations(primary, source.token).then(locations => { + if (!source.token.isCancellationRequested) { + this._update(locations); + } + }, onUnexpectedError); + } + + private _update(provided: readonly IFolderLocation[]): void { + const workspace = this._workspaceContextService.getWorkspace(); + const folder = this._movableFolder(); + const atAlternate = !!folder && this._folderLocationService.isAtAlternateLocation(folder); + const current = folder && atAlternate ? this._folderLocationService.getLocation(folder) : undefined; + const locations = current && !provided.some(location => extUriBiasedIgnorePathCase.isEqual(location.uri, current.uri)) + ? [current, ...provided] + : provided; + const folders: URI[] = workspace.folders.map(windowFolder => windowFolder.uri); + const add = (uri: URI) => { + if (!folders.some(candidate => extUriBiasedIgnorePathCase.isEqual(candidate, uri))) { + folders.push(uri); + } + }; + // The folder's own location while it is at another, then its other locations + if (atAlternate && workspace.uri) { + add(workspace.uri); + } + for (const location of locations) { + add(location.uri); + } + this._locations.set(locations, undefined); + this._folders.set(folders, undefined); + } + + /** The window's folder, when it can move between its locations (see `IWorkspaceFolderLocationService.canSetLocation`). */ + private _movableFolder() { + const folder = this._workspaceContextService.getWorkspace().folders[0]; + return folder && this._folderLocationService.canSetLocation(folder) ? folder : undefined; + } +} + +/** + * While worktree support (`chat.editorWindow.enableWorktree`) and AI features are on, the chat part's new-session composers + * also offer the locations of the window's folder, and follow the folder as it moves, once the chat part exists. + */ +export class AgentHostWorktreeComposerFoldersContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.agentHostWorktreeComposerFolders'; + + constructor( + @IAgentsChatPartFrame frame: IAgentsChatPartFrame, + @IAgentsChatPartService chatPart: IAgentsChatPartService, + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IInstantiationService instantiationService: IInstantiationService, + @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, + ) { + super(); + if (environmentService.isSessionsWindow) { + return; + } + const settingEnabled = observableConfigValue(ChatConfiguration.EditorWindowEnableWorktree, false, configurationService); + const aiEnabled = observableContextKey(ChatContextKeys.enabled.key, contextKeyService); + // Reads the chat part only once it exists, rather than creating it + const active = derived(reader => !!settingEnabled.read(reader) && !!aiEnabled.read(reader) && frame.contentCreated.read(reader)); + this._register(autorun(reader => { + if (!active.read(reader)) { + return; + } + const folders = reader.store.add(instantiationService.createInstance(AgentHostWorktreeComposerFolders)); + reader.store.add(chatPart.setComposerFolderOptions(() => { + folders.refresh(); + return { allowedFolders: folders.folders, getFolderPresentation: folderUri => folders.getPresentation(folderUri) }; + })); + // Its composers aimed at where the folder was aim at where it is now + reader.store.add(folders.onDidMoveFolder(({ from, to }) => chatPart.retargetComposerFolders(to, from))); + folders.refresh(); + })); + } +} + +registerWorkbenchContribution2(AgentHostWorktreeComposerFoldersContribution.ID, AgentHostWorktreeComposerFoldersContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts new file mode 100644 index 00000000000000..28e072aad9d302 --- /dev/null +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts @@ -0,0 +1,242 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IFolderLocation, IWorkspaceFolderLocationService } from '../../../../../../platform/workspace/common/folderLocations.js'; +import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder, IWorkspaceFoldersChangeEvent, WorkbenchState } from '../../../../../../platform/workspace/common/workspace.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { MockContextKeyService } from '../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { observableValue } from '../../../../../../base/common/observable.js'; +import { toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; +import { ChatContextKeys } from '../../../../chat/common/actions/chatContextKeys.js'; +import { ChatConfiguration } from '../../../../chat/common/constants.js'; +import { IAgentsChatPartComposerFolderOptions, IAgentsChatPartService } from '../../../browser/agentsView/agentsChatPart.js'; +import { IAgentsChatPartFrame } from '../../../browser/agentsView/agentsChatPartFrame.js'; +import { AgentHostWorktreeComposerFolders, AgentHostWorktreeComposerFoldersContribution } from '../../../browser/agentsView/agentHostWorktreeComposerFolders.js'; + +suite('AgentHostWorktreeComposerFolders', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + const worktree = (path: string, branch: string): IFolderLocation => ({ uri: URI.file(path), label: path.split('/').pop()!, description: branch, icon: Codicon.gitBranchCheckout }); + + function create(options: { folders: { uri: string; primaryUri?: string }[]; remote?: boolean; currentLocation?: IFolderLocation; noLocations?: boolean }) { + const onDidChangeWorkspaceFolders = store.add(new Emitter()); + const onDidChangeProviders = store.add(new Emitter()); + const onDidChangeWorkbenchState = store.add(new Emitter()); + let folders = options.folders; + let locations: IFolderLocation[] = options.noLocations ? [] : [worktree('/work/vscode.worktrees/a', 'feature-a'), worktree('/work/vscode.worktrees/b', 'feature-b')]; + const asked: string[] = []; + const instantiationService = store.add(new TestInstantiationService()); + // The workspace's own location is the folder's primary location; it does not change as the folder moves + instantiationService.stub(IWorkspaceContextService, upcastPartial({ + onDidChangeWorkspaceFolders: onDidChangeWorkspaceFolders.event, + getWorkspace: () => upcastPartial({ + uri: folders.length === 1 ? URI.file(folders[0].primaryUri ?? folders[0].uri) : undefined, + folders: folders.map(folder => upcastPartial({ uri: URI.file(folder.uri) })), + }), + })); + // As the workspace answers: only the folder of a local single-folder window can move + const atAlternate = (folder: IWorkspaceFolder) => folders.some(candidate => candidate.primaryUri && candidate.uri === folder.uri.path); + instantiationService.stub(IWorkspaceFolderLocationService, upcastPartial({ + onDidChangeProviders: onDidChangeProviders.event, + onDidChangeLocation: Event.None, + // As the service has it: whether a folder can move changes also as the window's folders change + onDidChangeCanSetLocation: Event.any(onDidChangeWorkbenchState.event, onDidChangeWorkspaceFolders.event) as Event, + canSetLocation: () => folders.length === 1 && !options.remote, + isAtAlternateLocation: atAlternate, + getLocation: folder => atAlternate(folder) && options.currentLocation ? { ...options.currentLocation, providerId: 'agentHost.sessionWorktrees', builtIn: true } : undefined, + getLocations: async primary => { asked.push(primary.path); return locations; }, + })); + const composerFolders = store.add(instantiationService.createInstance(AgentHostWorktreeComposerFolders)); + composerFolders.refresh(); + return { + composerFolders, + asked, + snapshot: () => ({ + folders: composerFolders.folders.get().map(uri => uri.path), + presentations: composerFolders.folders.get().map(uri => composerFolders.getPresentation(uri)?.description ?? null), + }), + moveTo: (next: { uri: string; primaryUri?: string }[]) => { folders = next; onDidChangeWorkspaceFolders.fire({ added: [], removed: [], changed: [] }); }, + addWorktree: (location: IFolderLocation) => { locations = [...locations, location]; }, + providersChanged: () => onDidChangeProviders.fire(), + }; + } + + test('offers the folder of a local window and its linked checkouts, by their branch, from the folder\'s primary location also while the folder is at one of them', async () => { + const { composerFolders, asked, snapshot, moveTo, addWorktree } = create({ folders: [{ uri: '/work/vscode' }] }); + await timeout(0); + const atPrimary = snapshot(); + moveTo([{ uri: '/work/vscode.worktrees/a', primaryUri: '/work/vscode' }]); + await timeout(0); + const atWorktree = snapshot(); + addWorktree(worktree('/work/vscode.worktrees/c', 'feature-c')); + composerFolders.refresh(); + await timeout(0); + + assert.deepStrictEqual({ atPrimary, atWorktree, afterNewWorktree: snapshot().folders, asked }, { + atPrimary: { folders: ['/work/vscode', '/work/vscode.worktrees/a', '/work/vscode.worktrees/b'], presentations: [null, 'feature-a', 'feature-b'] }, + atWorktree: { folders: ['/work/vscode.worktrees/a', '/work/vscode', '/work/vscode.worktrees/b'], presentations: ['feature-a', null, 'feature-b'] }, + afterNewWorktree: ['/work/vscode.worktrees/a', '/work/vscode', '/work/vscode.worktrees/b', '/work/vscode.worktrees/c'], + asked: ['/work/vscode', '/work/vscode', '/work/vscode'], + }); + }); + + test('reads the folder\'s locations once as the window\'s folders change, which also changes whether the folder can move', async () => { + const { asked, moveTo } = create({ folders: [{ uri: '/work/vscode' }] }); + await timeout(0); + asked.splice(0); + moveTo([{ uri: '/work/vscode.worktrees/a', primaryUri: '/work/vscode' }]); + await timeout(0); + + assert.deepStrictEqual(asked, ['/work/vscode']); + }); + + test('as the window\'s folder moves to another location, it offers it at once and tells where it moved from and to; not as it changes to another repository', async () => { + const { composerFolders, snapshot, moveTo } = create({ folders: [{ uri: '/work/vscode' }] }); + const moves: string[] = []; + store.add(composerFolders.onDidMoveFolder(({ from, to }) => moves.push(`${from.path} -> ${to.path}`))); + await timeout(0); + moveTo([{ uri: '/work/vscode.worktrees/a', primaryUri: '/work/vscode' }]); + const atOnce = snapshot().folders; + await timeout(0); + moveTo([{ uri: '/work/other' }]); + + assert.deepStrictEqual({ atOnce, moves }, { + atOnce: ['/work/vscode.worktrees/a', '/work/vscode', '/work/vscode.worktrees/b'], + moves: ['/work/vscode -> /work/vscode.worktrees/a'], + }); + }); + + test('the location the window\'s folder is at shows as that worktree, by its branch, also where its provider lists none', async () => { + const { snapshot } = create({ folders: [{ uri: '/work/vscode.worktrees/a', primaryUri: '/work/vscode' }], currentLocation: worktree('/work/vscode.worktrees/a', 'feature-a'), noLocations: true }); + await timeout(0); + + assert.deepStrictEqual(snapshot(), { folders: ['/work/vscode.worktrees/a', '/work/vscode'], presentations: ['feature-a', null] }); + }); + + test('in a window at a worktree, the primary folder presents as a folder and the current worktree as the current worktree', async () => { + const { composerFolders } = create({ folders: [{ uri: '/work/vscode.worktrees/a', primaryUri: '/work/vscode' }], currentLocation: worktree('/work/vscode.worktrees/a', 'feature-a') }); + await timeout(0); + const present = (path: string) => { + const presentation = composerFolders.getPresentation(URI.file(path)); + return { kind: presentation?.kind, current: presentation?.current }; + }; + + assert.deepStrictEqual({ primary: present('/work/vscode'), current: present('/work/vscode.worktrees/a'), other: present('/work/vscode.worktrees/b') }, { + primary: { kind: 'folder', current: false }, + current: { kind: 'worktree', current: true }, + other: { kind: 'worktree', current: false }, + }); + }); + + test('offers only the window\'s folders where its folder cannot move: several folders, a remote window, no folder', async () => { + const multiRoot = create({ folders: [{ uri: '/work/vscode' }, { uri: '/work/tools' }] }); + const remote = create({ folders: [{ uri: '/work/vscode' }], remote: true }); + const empty = create({ folders: [] }); + await timeout(0); + + assert.deepStrictEqual({ multiRoot: multiRoot.snapshot().folders, remote: remote.snapshot().folders, empty: empty.snapshot().folders, asked: [...multiRoot.asked, ...remote.asked, ...empty.asked] }, { + multiRoot: ['/work/vscode', '/work/tools'], + remote: ['/work/vscode'], + empty: [], + asked: [], + }); + }); + + test('cancels a read under way as it is disposed, and does not take its result', async () => { + const read = new DeferredPromise(); + let token: CancellationToken | undefined; + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IWorkspaceContextService, upcastPartial({ + onDidChangeWorkspaceFolders: Event.None, + getWorkspace: () => upcastPartial({ uri: URI.file('/work/vscode'), folders: [upcastPartial({ uri: URI.file('/work/vscode') })] }), + })); + instantiationService.stub(IWorkspaceFolderLocationService, upcastPartial({ + onDidChangeProviders: Event.None, + onDidChangeLocation: Event.None, + onDidChangeCanSetLocation: Event.None, + canSetLocation: () => true, + isAtAlternateLocation: () => false, + getLocations: (_primary, cancellation) => { token = cancellation; return read.p; }, + })); + const composerFolders = instantiationService.createInstance(AgentHostWorktreeComposerFolders); + composerFolders.refresh(); + composerFolders.dispose(); + const cancelled = token?.isCancellationRequested; + read.complete([worktree('/work/vscode.worktrees/a', 'feature-a')]); + await timeout(0); + + assert.deepStrictEqual({ cancelled, folders: composerFolders.folders.get().map(uri => uri.path) }, { cancelled: true, folders: ['/work/vscode'] }); + }); + + test('reads the locations again as providers change', async () => { + const { asked, providersChanged } = create({ folders: [{ uri: '/work/vscode' }] }); + await timeout(0); + providersChanged(); + await timeout(0); + + assert.deepStrictEqual(asked, ['/work/vscode', '/work/vscode']); + }); + + test('with worktree support and AI features on, once the chat part exists, its composers offer the folder\'s locations and follow the folder as it moves', async () => { + const onDidChangeWorkspaceFolders = store.add(new Emitter()); + let at = '/work/vscode'; + const configurationService = new TestConfigurationService({ [ChatConfiguration.EditorWindowEnableWorktree]: true }); + const contextKeyService = store.add(new MockContextKeyService()); + contextKeyService.createKey(ChatContextKeys.enabled.key, true); + const contentCreated = observableValue('contentCreated', false); + const calls: string[] = []; + let options: (() => IAgentsChatPartComposerFolderOptions) | undefined; + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IWorkspaceContextService, upcastPartial({ + onDidChangeWorkspaceFolders: onDidChangeWorkspaceFolders.event, + getWorkspace: () => upcastPartial({ uri: URI.file('/work/vscode'), folders: [upcastPartial({ uri: URI.file(at) })] }), + })); + instantiationService.stub(IWorkspaceFolderLocationService, upcastPartial({ + onDidChangeProviders: Event.None, + onDidChangeLocation: Event.None, + onDidChangeCanSetLocation: onDidChangeWorkspaceFolders.event as Event as Event, + canSetLocation: () => true, + isAtAlternateLocation: folder => folder.uri.path !== '/work/vscode', + getLocation: () => undefined, + getLocations: async () => [worktree('/work/vscode.worktrees/a', 'feature-a')], + })); + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(IContextKeyService, contextKeyService); + instantiationService.stub(IWorkbenchEnvironmentService, upcastPartial({ isSessionsWindow: false })); + instantiationService.stub(IAgentsChatPartFrame, upcastPartial({ contentCreated })); + instantiationService.stub(IAgentsChatPartService, upcastPartial({ + setComposerFolderOptions: provider => { calls.push('options'); options = provider; return toDisposable(() => { calls.push('options cleared'); options = undefined; }); }, + retargetComposerFolders: (to: URI, from: URI) => { calls.push(`retarget ${from.path} -> ${to.path}`); }, + })); + store.add(instantiationService.createInstance(AgentHostWorktreeComposerFoldersContribution)); + const beforeCreated = [...calls]; + contentCreated.set(true, undefined); + await timeout(0); + const offered = options?.().allowedFolders?.get().map(uri => uri.path); + at = '/work/vscode.worktrees/a'; + onDidChangeWorkspaceFolders.fire({ added: [], removed: [], changed: [] }); + configurationService.setUserConfiguration(ChatConfiguration.EditorWindowEnableWorktree, false); + configurationService.onDidChangeConfigurationEmitter.fire({ affectsConfiguration: (key: string) => key === ChatConfiguration.EditorWindowEnableWorktree } as never); + + assert.deepStrictEqual({ beforeCreated, offered, calls }, { + beforeCreated: [], + offered: ['/work/vscode', '/work/vscode.worktrees/a'], + calls: ['options', 'retarget /work/vscode -> /work/vscode.worktrees/a', 'options cleared'], + }); + }); +}); diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index 1b540d796dc78c..9a8886db1a7f0e 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -243,6 +243,7 @@ import './contrib/mcp/browser/mcp.view.contribution.js'; import './contrib/chat/browser/chatSessions/chatSessions.contribution.js'; import './contrib/sessions/browser/editorWindow/editorWindowSessions.contribution.js'; import './contrib/sessions/providers/agentsView/browser/agentsViewProviders.contribution.js'; +import './contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.js'; import './contrib/chat/browser/contextContrib/chatContext.contribution.js'; import './contrib/imageCarousel/browser/imageCarousel.contribution.js'; From ecbf8fc17efc0025ff304f5e8350c7650ab21b92 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 06:15:17 +0100 Subject: [PATCH 050/165] Editor window sessions: the folders of new-session composers in a service of their own IEditorWindowComposerFoldersService offers the folders that the window's new-session composers create sessions in (the window's folders, unless a feature offers others), so a composer outside the chat part, such as the Chat view's, reads them without creating the chat part, whose creation shows its chat as the window's. The chat part's getComposerFolderOptions/setComposerFolderOptions delegate to it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentsView/agentsChatPart.ts | 27 +++------ .../editorWindowComposerFolders.ts | 59 +++++++++++++++++++ .../editorWindowSessions.contribution.ts | 3 + .../browser/agentsView/agentsChatPart.test.ts | 2 + .../editorWindowComposerFolders.test.ts | 38 ++++++++++++ 5 files changed, 110 insertions(+), 19 deletions(-) create mode 100644 src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowComposerFolders.ts create mode 100644 src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowComposerFolders.test.ts diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts index 08e91e485ac092..1cbb7e78cc605a 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts @@ -8,7 +8,7 @@ import { raceTimeout } from '../../../../../base/common/async.js'; import { onUnexpectedError } from '../../../../../base/common/errors.js'; import { Event } from '../../../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { constObservable, derived, IObservable, ITransaction, observableFromEvent, observableValue, transaction, waitForState } from '../../../../../base/common/observable.js'; +import { constObservable, derived, IObservable, ITransaction, observableValue, transaction, waitForState } from '../../../../../base/common/observable.js'; import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -20,7 +20,6 @@ import { createDecorator, IInstantiationService } from '../../../../../platform/ import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; -import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { EditorInput } from '../../../../common/editor/editorInput.js'; import { GroupDirection, IContentPart, IEditorGroup, IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; @@ -36,8 +35,9 @@ import { enforceChatPartOptions } from '../../../chatWindow/browser/chatWindowSe import { ChatWindowLayoutMode } from '../../../chatWindow/common/chatWindowLayout.js'; import { ISessionsManagementService } from '../../common/sessionsManagement.js'; import { IChatEditorWindow, registerSessionChatViewEditor, SESSION_CHAT_VIEW_WORKING_SET_HOST_OPTIONS } from '../chatEditor/chatEditor.js'; -import { ChatWindowComposerModel, ChatWindowComposers, createChatWindowComposerResource, IChatWindowComposerInit, isChatWindowComposerResource } from '../chatEditor/chatWindowComposer.js'; +import { ChatWindowComposerModel, ChatWindowComposers, createChatWindowComposerResource, isChatWindowComposerResource } from '../chatEditor/chatWindowComposer.js'; import { IChatViewHostShownSession } from '../../../chat/browser/chatViewHost.js'; +import { IEditorWindowComposerFolderOptions, IEditorWindowComposerFoldersService } from '../editorWindow/editorWindowComposerFolders.js'; import { SessionChatEditorEntry, waitForSessionChat } from '../chatEditor/sessionChatEditorHost.js'; import { IChatViewFactory } from '../chatViewFactory.js'; import { INewSessionTarget } from '../parts/chatView.js'; @@ -127,7 +127,7 @@ export interface IAgentsChatPartService { */ readonly shownSession: IObservable; - /** The folders its composers create sessions in, and how their workspace pickers show them: the window's folders by default. */ + /** The folders its composers create sessions in, and how their workspace pickers show them (see `IEditorWindowComposerFoldersService`). */ getComposerFolderOptions(): IAgentsChatPartComposerFolderOptions; /** @@ -144,7 +144,7 @@ export interface IAgentsChatPartService { } /** The folders the composers of the chat part create sessions in, besides the folder each aims at, and how they show them. */ -export type IAgentsChatPartComposerFolderOptions = Pick; +export type IAgentsChatPartComposerFolderOptions = IEditorWindowComposerFolderOptions; /** * Shows again the chats of the chat part that the layout restored shown. With chat hidden or disabled as the window starts, @@ -216,7 +216,6 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart */ private readonly _composerTabs = observableValue>(this, new Set()); readonly shownSession: IObservable; - private readonly _composerFolderProviders: (() => IAgentsChatPartComposerFolderOptions)[] = []; /** The composer of each composer's tab, which the tab disposes. */ private readonly _composerModels = new Map(); readonly activeChat: IObservable; @@ -244,10 +243,9 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart @IChatViewFactory private readonly _chatViewFactory: IChatViewFactory, @IConfigurationService private readonly _configurationService: IConfigurationService, @ITelemetryService private readonly _telemetryService: ITelemetryService, - @IWorkspaceContextService workspaceContextService: IWorkspaceContextService, + @IEditorWindowComposerFoldersService private readonly _composerFolders: IEditorWindowComposerFoldersService, ) { super(); - this._windowFolders = observableFromEvent(this, workspaceContextService.onDidChangeWorkspaceFolders, () => workspaceContextService.getWorkspace().folders.map(folder => folder.uri)); this.activeChat = derived(this, reader => { const input = this._activeInput.read(reader); @@ -304,9 +302,6 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart this._frame.didCreateContent(); } - /** The folders of the window, the only ones its composers create sessions in by default, besides the folder each aims at. */ - private readonly _windowFolders: IObservable; - retargetComposerFolders(to: URI, from: URI): void { for (const composer of this._composerModels.values()) { composer.retargetFolder(to, from); @@ -314,17 +309,11 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart } getComposerFolderOptions(): IAgentsChatPartComposerFolderOptions { - return this._composerFolderProviders.at(-1)?.() ?? { allowedFolders: this._windowFolders }; + return this._composerFolders.getComposerFolderOptions(); } setComposerFolderOptions(provider: () => IAgentsChatPartComposerFolderOptions): IDisposable { - this._composerFolderProviders.push(provider); - return toDisposable(() => { - const index = this._composerFolderProviders.indexOf(provider); - if (index >= 0) { - this._composerFolderProviders.splice(index, 1); - } - }); + return this._composerFolders.setComposerFolderOptions(provider); } private get composers(): ChatWindowComposers { diff --git a/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowComposerFolders.ts b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowComposerFolders.ts new file mode 100644 index 00000000000000..8e016716e1e700 --- /dev/null +++ b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowComposerFolders.ts @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { IObservable, observableFromEvent } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IChatWindowComposerInit } from '../chatEditor/chatWindowComposer.js'; + +/** The folders new-session composers create sessions in, besides the folder each aims at, and how they show them. */ +export type IEditorWindowComposerFolderOptions = Pick; + +export const IEditorWindowComposerFoldersService = createDecorator('editorWindowComposerFoldersService'); + +/** + * The folders that the new-session composers of an editor window offer, such as those of the chat part and of the Chat + * view's sessions: the window's folders, unless a feature offers others, such as the worktrees of the window's folder. + * Reading them shows nothing, unlike the chat part, whose creation shows its chat as the window's. + */ +export interface IEditorWindowComposerFoldersService { + readonly _serviceBrand: undefined; + + /** The folders its composers create sessions in, and how their workspace pickers show them. */ + getComposerFolderOptions(): IEditorWindowComposerFolderOptions; + + /** Has the composers offer the folders `provider` gives until the returned disposable is disposed; the one set last applies. */ + setComposerFolderOptions(provider: () => IEditorWindowComposerFolderOptions): IDisposable; +} + +export class EditorWindowComposerFoldersService implements IEditorWindowComposerFoldersService { + + declare readonly _serviceBrand: undefined; + + /** One per call of {@link setComposerFolderOptions}, also of the same provider. */ + private readonly _providers: { readonly provider: () => IEditorWindowComposerFolderOptions }[] = []; + private readonly _windowFolders: IObservable; + + constructor(@IWorkspaceContextService workspaceContextService: IWorkspaceContextService) { + this._windowFolders = observableFromEvent(this, workspaceContextService.onDidChangeWorkspaceFolders, () => workspaceContextService.getWorkspace().folders.map(folder => folder.uri)); + } + + getComposerFolderOptions(): IEditorWindowComposerFolderOptions { + return this._providers.at(-1)?.provider() ?? { allowedFolders: this._windowFolders }; + } + + setComposerFolderOptions(provider: () => IEditorWindowComposerFolderOptions): IDisposable { + const registration = { provider }; + this._providers.push(registration); + return toDisposable(() => { + const index = this._providers.indexOf(registration); + if (index >= 0) { + this._providers.splice(index, 1); + } + }); + } +} diff --git a/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessions.contribution.ts b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessions.contribution.ts index e0f2b0c89fbe0e..ca2397bd0c47fa 100644 --- a/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessions.contribution.ts +++ b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessions.contribution.ts @@ -69,6 +69,7 @@ import { EditorWindowSessionsRuntime, IEditorWindowSessionsRuntime } from './edi import { AgentsChatPartFrame, IAgentsChatPartFrame } from '../agentsView/agentsChatPartFrame.js'; import { AgentsChatPartService, IAgentsChatPartService } from '../agentsView/agentsChatPart.js'; import { EditorWindowChatPartService, IEditorWindowChatPartService } from '../agentsView/agentsChatPartContribution.js'; +import { EditorWindowComposerFoldersService, IEditorWindowComposerFoldersService } from './editorWindowComposerFolders.js'; // The sessions services of an editor window, for the sessions features it can host. Every one is created only as it is // first used, and nothing in the window uses them until such a feature does. @@ -132,3 +133,5 @@ registerSingleton(IEditorWindowSessionsRuntime, EditorWindowSessionsRuntime, Ins registerSingleton(IEditorWindowChatPartService, EditorWindowChatPartService, InstantiationType.Delayed); registerSingleton(IAgentsChatPartFrame, AgentsChatPartFrame, InstantiationType.Delayed); registerSingleton(IAgentsChatPartService, AgentsChatPartService, InstantiationType.Delayed); +// The folders that the window's new-session composers offer, which reading does not create the chat part for +registerSingleton(IEditorWindowComposerFoldersService, EditorWindowComposerFoldersService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts index 2aa62c86d32dcd..6ae8fff9f49acc 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts @@ -21,6 +21,7 @@ import { EditorInputWithOptions } from '../../../../../common/editor.js'; import { EditorInput } from '../../../../../common/editor/editorInput.js'; import { IContentPart, IEditorGroupsService } from '../../../../../services/editor/common/editorGroupsService.js'; import { IWorkspace, IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { EditorWindowComposerFoldersService, IEditorWindowComposerFoldersService } from '../../../browser/editorWindow/editorWindowComposerFolders.js'; import { IEditorService, PreferredGroup } from '../../../../../services/editor/common/editorService.js'; import { IPartVisibilityChangeEvent, IWorkbenchLayoutService, Parts } from '../../../../../services/layout/browser/layoutService.js'; import { ISessionHost } from '../../../../../services/sessions/browser/sessionHost.js'; @@ -197,6 +198,7 @@ suite('AgentsChatPartService', () => { instantiationService.stub(IChatService, upcastPartial({ acquireExistingSession: () => undefined })); instantiationService.stub(IChatViewFactory, upcastPartial({})); instantiationService.stub(IWorkspaceContextService, upcastPartial({ onDidChangeWorkspaceFolders: Event.None, getWorkspace: () => upcastPartial({ folders: [] }) })); + instantiationService.stub(IEditorWindowComposerFoldersService, instantiationService.createInstance(EditorWindowComposerFoldersService)); // The sessions, of which each composer holds a slot for the session it creates; the listeners on their events count // The changes of the listed sessions, and of each session, also of those the providers' listing scope hides diff --git a/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowComposerFolders.test.ts b/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowComposerFolders.test.ts new file mode 100644 index 00000000000000..44e235f2dae859 --- /dev/null +++ b/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowComposerFolders.test.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../../../base/common/event.js'; +import { constObservable } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; +import { EditorWindowComposerFoldersService } from '../../../browser/editorWindow/editorWindowComposerFolders.js'; + +suite('EditorWindowComposerFoldersService', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('offers the window\'s folders, or those of the provider set last, until each provider is disposed, also one set twice', () => { + const service = new EditorWindowComposerFoldersService(upcastPartial({ + onDidChangeWorkspaceFolders: Event.None, + getWorkspace: () => upcastPartial({ folders: [upcastPartial({ uri: URI.file('/repo') })] }), + })); + const folders = () => service.getComposerFolderOptions().allowedFolders?.get().map(folder => folder.path); + const worktrees = () => ({ allowedFolders: constObservable([URI.file('/repo'), URI.file('/repo.worktrees/a')]) }); + const steps = [folders()]; + + const first = service.setComposerFolderOptions(worktrees); + const second = service.setComposerFolderOptions(worktrees); + steps.push(folders()); + first.dispose(); + steps.push(folders()); + second.dispose(); + steps.push(folders()); + + assert.deepStrictEqual(steps, [['/repo'], ['/repo', '/repo.worktrees/a'], ['/repo', '/repo.worktrees/a'], ['/repo']]); + }); +}); From 259eb73bba8425d50c08031962beddbad90ca0ca Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 06:22:51 +0100 Subject: [PATCH 051/165] Worktree infra: report a first send's session as committed, also through an extension's commit, and follow symbolic links of worktree folders and .git files - ChatWidget marks the view model change to the session its first request made; the Chat view reports it as 'commit'. - IChatViewHostOpenOptions.commit: the extension commit path opens the committed session through the Chat view host as 'commit'. - The location provider compares the real paths of the repository's worktrees folder and of a worktree's .git file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostWorktreeLocations.ts | 8 +++-- src/vs/workbench/contrib/chat/browser/chat.ts | 2 ++ .../browser/chatSessions/chatSessionCommit.ts | 5 +++- .../contrib/chat/browser/chatViewHost.ts | 10 +++++++ .../contrib/chat/browser/widget/chatWidget.ts | 18 ++++++++++-- .../viewPane/chatViewHostService.ts | 17 ++++------- .../widgetHosts/viewPane/chatViewPane.ts | 24 +++++++++------ .../agentHostWorktreeLocations.test.ts | 29 ++++++++++++++----- .../viewPane/chatViewHostService.test.ts | 12 ++++---- .../widgetHosts/viewPane/chatViewPane.test.ts | 20 +++++++++++-- 10 files changed, 102 insertions(+), 43 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts index 90ab5600fb0c4b..c41433318306f6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts @@ -114,7 +114,8 @@ export class AgentHostWorktreeLocationProvider extends Disposable implements IWo } try { const checkout = await readFolderGitCheckout(this._fileService, await this._real(primary)); - return checkout && joinPath(await this._real(checkout.repository), '.git', 'worktrees'); + // Its real path also where `.git` or `.git/worktrees` is a symbolic link + return checkout && await this._real(joinPath(await this._real(checkout.repository), '.git', 'worktrees')); } catch (error) { this._logService.warn(`[AgentHostWorktreeLocations] Could not read the git repository of ${primary.toString()}`, error); return undefined; @@ -135,12 +136,13 @@ export class AgentHostWorktreeLocationProvider extends Disposable implements IWo return undefined; } const locationGitFile = joinPath(realLocation, '.git'); - const entry = await this._readLink(locationGitFile, dirname(locationGitFile), /^gitdir:\s*(?.+?)\s*$/m); + const entry = await this._readLink(locationGitFile, realLocation, /^gitdir:\s*(?.+?)\s*$/m); if (!entry || !extUriBiasedIgnorePathCase.isEqual(dirname(await this._real(entry)), worktreesDir)) { return undefined; } const backlink = await this._readLink(joinPath(entry, 'gitdir'), entry); - if (!backlink || !extUriBiasedIgnorePathCase.isEqual(await this._real(backlink), locationGitFile)) { + // The `.git` file by its real path, also where it is a symbolic link + if (!backlink || !extUriBiasedIgnorePathCase.isEqual(await this._real(backlink), await this._real(locationGitFile))) { return undefined; } // Its branch, from the entry's HEAD: read again each time the location is resolved diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 4fe575a02ab66b..4f53ff786eeae7 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -474,6 +474,8 @@ export interface IChatAcceptInputOptions { export interface IChatWidgetViewModelChangeEvent { readonly previousSessionResource: URI | undefined; readonly currentSessionResource: URI | undefined; + /** Whether the widget shows the session that its first request made of the one it showed, such as a new chat's. */ + readonly committed?: boolean; } /** diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionCommit.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionCommit.ts index 46567efbbc57b7..beb3f4df8ae80a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionCommit.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionCommit.ts @@ -18,6 +18,7 @@ import { ChatAgentLocation } from '../../common/constants.js'; import { IChatArtifactsService } from '../../common/tools/chatArtifactsService.js'; import { IChatTodoListService } from '../../common/tools/chatTodoListService.js'; import { IChatWidgetService, isIChatViewViewContext } from '../chat.js'; +import { IChatViewHostService } from '../chatViewHost.js'; import { IChatEditorOptions } from '../widgetHosts/editor/chatEditor.js'; import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js'; @@ -31,6 +32,7 @@ export async function commitChatSession(accessor: ServicesAccessor, chatSessionT const chatSessionsService = accessor.get(IChatSessionsService); const chatService = accessor.get(IChatService); const chatWidgetService = accessor.get(IChatWidgetService); + const chatViewHostService = accessor.get(IChatViewHostService); const chatTodoListService = accessor.get(IChatTodoListService); const chatArtifactsService = accessor.get(IChatArtifactsService); const chatDebugService = accessor.get(IChatDebugService); @@ -108,7 +110,8 @@ export async function commitChatSession(accessor: ServicesAccessor, chatSessionT const chatViewWidget = chatWidgetService.getWidgetBySessionResource(originalResource); if (chatViewWidget && isIChatViewViewContext(chatViewWidget.viewContext)) { - await chatWidgetService.openSession(modifiedResource, undefined, { preserveFocus: true }); + // The session its first request made: a host reports it as such, not as one the user opened + await chatViewHostService.openSession(modifiedResource, { preserveFocus: true, commit: true }); } else if (!chatViewWidget) { // No widget currently shows the original session — eagerly load the // session so the transferred state set above is materialized into a diff --git a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts index 023643c1774412..6638370bbe4e56 100644 --- a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts +++ b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts @@ -42,6 +42,16 @@ export interface IChatViewHostOpenOptions { * the user opens, such as the chat that was last shown where the user moved the window's folder. */ readonly restore?: boolean; + /** + * Shows the session as the one that the first request of the chat it shows made of it ({@link IChatViewHostShownSession.reason} + * `commit`), rather than one the user opens. + */ + readonly commit?: boolean; +} + +/** Why a host shows a session that it opens with `options`. */ +export function getChatViewHostOpenReason(options: IChatViewHostOpenOptions | undefined): IChatViewHostShownSession['reason'] { + return options?.commit ? 'commit' : options?.restore ? 'restore' : 'open'; } /** How {@link IChatViewHost.startSession} starts a session that its caller may send a first request to. */ diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index df31cc80632d84..7570f34a9237f5 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -362,6 +362,8 @@ export class ChatWidget extends Disposable implements IChatWidget { private _onDidFocus = this._register(new Emitter()); readonly onDidFocus = this._onDidFocus.event; + /** Whether it sets the model of the session its first request made, which its view model change reports. */ + private _settingCommittedModel = false; private _onDidChangeViewModel = this._register(new Emitter()); readonly onDidChangeViewModel = this._onDidChangeViewModel.event; private readonly _onDidChangeDraft = this._register(new Emitter()); @@ -579,7 +581,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.logService.debug('ChatWidget#setViewModel: no viewModel'); } - this._onDidChangeViewModel.fire({ previousSessionResource, currentSessionResource: this._viewModel?.sessionResource }); + this._onDidChangeViewModel.fire({ previousSessionResource, currentSessionResource: this._viewModel?.sessionResource, committed: this._settingCommittedModel || undefined }); this.updateSessionStateIndicator(); } @@ -3257,6 +3259,16 @@ export class ChatWidget extends Disposable implements IChatWidget { this.listWidget?.setPaddingBottom(this._getListPaddingBottom()); } + /** Shows the session that its first request made of the one it shows, such as an untitled chat's committed session. */ + private setCommittedModel(model: IChatModel): void { + this._settingCommittedModel = true; + try { + this.setModel(model); + } finally { + this._settingCommittedModel = false; + } + } + setModel(model: IChatModel | undefined): void { if (!this.container || !this.inputPart) { // Widget hasn't finished rendering yet; skip rather than crash and @@ -4305,7 +4317,7 @@ export class ChatWidget extends Disposable implements IChatWidget { if (result.newSessionResource) { const newModel = this.chatService.getSession(result.newSessionResource); if (newModel) { - this.setModel(newModel); + this.setCommittedModel(newModel); } } return; @@ -4359,7 +4371,7 @@ export class ChatWidget extends Disposable implements IChatWidget { if (sent.newSessionResource) { const newModel = this.chatService.getSession(sent.newSessionResource); if (newModel) { - this.setModel(newModel); + this.setCommittedModel(newModel); } } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts index 909d034271a63f..852d04abf0ebfc 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts @@ -14,7 +14,7 @@ import { IViewDescriptorService, ViewContainerLocation } from '../../../../../co import { IWorkbenchLayoutService, Parts } from '../../../../../services/layout/browser/layoutService.js'; import { IViewsService } from '../../../../../services/views/common/viewsService.js'; import { ChatViewId, IChatWidget } from '../../chat.js'; -import { IChatViewHost, IChatViewHostOpenOptions, IChatViewHostRevealOptions, IChatViewHostService, IChatViewHostSessionStart, IChatViewHostSessionStartOptions, IChatViewHostShownSession } from '../../chatViewHost.js'; +import { getChatViewHostOpenReason, IChatViewHost, IChatViewHostOpenOptions, IChatViewHostRevealOptions, IChatViewHostService, IChatViewHostSessionStart, IChatViewHostSessionStartOptions, IChatViewHostShownSession } from '../../chatViewHost.js'; import { ChatViewPane } from './chatViewPane.js'; /** The Chat view, as the host of its chats. */ @@ -53,24 +53,17 @@ export class ChatViewPaneHost implements IChatViewHost { if (view && !options?.preserveFocus) { view.focus(); } - if (options?.restore) { - view?.reportRestored(sessionResource); - } else { - view?.reportOpened(sessionResource); - } + view?.reportShown(sessionResource, getChatViewHostOpenReason(options)); return view?.widget; } async openSession(sessionResource: URI, options?: IChatViewHostOpenOptions): Promise { const chatView = await this.viewsService.openView(ChatViewId, !options?.preserveFocus); if (chatView) { - await chatView.loadSession(sessionResource, options?.sessionTypeSelectionReason, options?.restore ? 'restore' : 'open'); + const reason = getChatViewHostOpenReason(options); + await chatView.loadSession(sessionResource, options?.sessionTypeSelectionReason, reason); // Also a chat it showed already, which loading does not set again - if (options?.restore) { - chatView.reportRestored(sessionResource); - } else { - chatView.reportOpened(sessionResource); - } + chatView.reportShown(sessionResource, reason); if (!options?.preserveFocus) { chatView.focusInput(); } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index a5e2465fae8963..bf22c302e18139 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -70,7 +70,7 @@ import { IChatViewsWelcomeDescriptor } from '../../viewsWelcome/chatViewsWelcome import { IWorkbenchLayoutService, LayoutSettings, Position } from '../../../../../services/layout/browser/layoutService.js'; import { AgentSessionsViewerOrientation, AgentSessionsViewerPosition } from '../../agentSessions/agentSessions.js'; import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; -import { CHAT_WIDGET_VIEW_STATE_CACHE_LIMIT, ChatViewId, IChatWidgetService, IChatWidgetViewState, setModelPreservingInputTypedWhileLoading } from '../../chat.js'; +import { CHAT_WIDGET_VIEW_STATE_CACHE_LIMIT, ChatViewId, IChatWidgetService, IChatWidgetViewModelChangeEvent, IChatWidgetViewState, setModelPreservingInputTypedWhileLoading } from '../../chat.js'; import { IChatViewHostShownSession } from '../../chatViewHost.js'; import { IActivityService, ProgressBadge } from '../../../../../services/activity/common/activity.js'; import { DeferredPromise, disposableTimeout, raceCancellation, RunOnceScheduler } from '../../../../../../base/common/async.js'; @@ -1249,11 +1249,11 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { })); // Track the active chat model and reveal it in the sessions control if side-by-side - this._register(chatWidget.onDidChangeViewModel(() => { + this._register(chatWidget.onDidChangeViewModel(e => { const model = chatWidget.viewModel?.model; this.titleControl?.update(model); this._currentSessionResource.set(chatWidget.viewModel?.sessionResource, undefined); - this.updateShownSession(); + this.onDidChangeShownModel(e); if (this.sessionsViewerOrientation === AgentSessionsViewerOrientation.Stacked) { return; // only reveal in side-by-side mode @@ -1508,7 +1508,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { * one it restored, or one the user picks again in a sessions list, which is reported again. */ reportOpened(sessionResource: URI): void { - this._reportShown(sessionResource, 'open'); + this.reportShown(sessionResource, 'open'); } /** @@ -1516,10 +1516,11 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { * as the last chat of where the user moved the window's folder. */ reportRestored(sessionResource: URI): void { - this._reportShown(sessionResource, 'restore'); + this.reportShown(sessionResource, 'restore'); } - private _reportShown(sessionResource: URI, reason: 'open' | 'restore'): void { + /** Reports the chat of `sessionResource`, where the view shows it, as shown for `reason` ({@link shownSession}). */ + reportShown(sessionResource: URI, reason: IChatViewHostShownSession['reason']): void { const shown = this._shownSession.get(); // Each time the user opens it, also where it is reported as opened already, as where it works may matter again if (shown && (reason === 'open' || shown.reason !== reason) && isEqual(shown.resource, sessionResource)) { @@ -1527,10 +1528,15 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } } + /** Reports the chat its widget shows now, such as the session that its first request made, which the user did not pick. */ + private onDidChangeShownModel(e: IChatWidgetViewModelChangeEvent): void { + this.updateShownSession(e.committed ? 'commit' : undefined); + } + /** Reports the chat that the view shows now ({@link shownSession}), as its widget's chat changes. */ - private updateShownSession(): void { + private updateShownSession(reason: IChatViewHostShownSession['reason'] = this._showReason): void { const shownResource = this._widget.viewModel?.sessionResource; - this._shownSession.set(shownResource && { resource: shownResource, reason: this._showReason }, undefined); + this._shownSession.set(shownResource && { resource: shownResource, reason }, undefined); } /** @param showReason why it shows the chat ({@link shownSession}). */ @@ -1687,7 +1693,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } /** @param showReason why it shows the chat ({@link shownSession}). */ - async loadSession(sessionResource: URI, sessionTypeSelectionReason?: SessionTypeSelectionReason, showReason: 'open' | 'restore' = 'open'): Promise { + async loadSession(sessionResource: URI, sessionTypeSelectionReason?: SessionTypeSelectionReason, showReason: IChatViewHostShownSession['reason'] = 'open'): Promise { const t0 = Date.now(); this.logService.trace(`[ChatViewPane] loadSession start uri=${sessionResource.toString()}`); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts index 6bb8d11ce4a5c5..5596642977a05a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts @@ -35,21 +35,22 @@ suite('AgentHostWorktreeLocations', () => { * which `loading` holds, while another provider, `remote`, has resolved. */ function createProvider(entries: Record, sessions: readonly IAgentSession[], uncached: readonly IAgentSession[] = [], links: Record = {}, loading?: Promise) { + // A path through a symbolic link, such as `links['/link/vscode'] = '/work/vscode'`, is the path it links to + const real = (path: string) => { + const link = Object.keys(links).find(candidate => path === candidate || path.startsWith(`${candidate}/`)); + return link ? links[link] + path.slice(link.length) : path; + }; const fileService = upcastPartial({ stat: async (resource: URI) => { - const entry = entries[resource.path]; + const entry = entries[real(resource.path)]; if (entry === undefined) { throw new FileOperationError('not found', FileOperationResult.FILE_NOT_FOUND); } return upcastPartial({ isDirectory: entry === true }); }, - // A path through a symbolic link, such as `links['/link/vscode'] = '/work/vscode'`, is the path it links to - realpath: async (resource: URI) => { - const link = Object.keys(links).find(path => resource.path === path || resource.path.startsWith(`${path}/`)); - return link ? URI.file(links[link] + resource.path.slice(link.length)) : resource; - }, + realpath: async (resource: URI) => URI.file(real(resource.path)), readFile: async (resource: URI) => { - const entry = entries[resource.path]; + const entry = entries[real(resource.path)]; if (typeof entry !== 'string') { throw new FileOperationError('not found', FileOperationResult.FILE_NOT_FOUND); } @@ -184,4 +185,18 @@ suite('AgentHostWorktreeLocations', () => { offered: (await provider.provideLocations(URI.file('/link/vscode'), CancellationToken.None)).map(location => location.uri.path), }, { throughLinkedPrimary: 'feature', linkedWorktree: 'feature', primaryItself: 'none', offered: ['/alias/feature'] }); }); + + test('follows symbolic links of the repository\'s worktrees folder and of a worktree\'s .git file, and still rejects one that git does not link back', async () => { + // The repository's `.git/worktrees` is a link to another folder, and the worktree's `.git` file a link to another file + const linked = { + '/work/vscode/.git': true, + '/store/worktrees/feature/gitdir': '/store/feature.git\n', + '/store/feature.git': 'gitdir: /work/vscode/.git/worktrees/feature\n', + '/work/impostor/.git': 'gitdir: /work/vscode/.git/worktrees/feature\n', + }; + const provider = createProvider(linked, [], [], { '/work/vscode/.git/worktrees': '/store/worktrees', '/work/vscode.worktrees/feature/.git': '/store/feature.git' }); + const resolve = async (path: string) => (await provider.resolveLocation(URI.file('/work/vscode'), URI.file(path), CancellationToken.None))?.uri.path ?? 'none'; + + assert.deepStrictEqual({ linked: await resolve('/work/vscode.worktrees/feature'), impostor: await resolve('/work/impostor') }, { linked: '/work/vscode.worktrees/feature', impostor: 'none' }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts index 98e0d28e5c0f94..69a52095d5bfa4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts @@ -39,12 +39,11 @@ suite('ChatViewHostService', () => { shownSession, widget: options.widgetCreatedLater ? undefined : widget, get whenWidget() { accesses++; return created.p; }, - loadSession: async (resource: URI, reason?: string, showReason?: string) => { calls.push(`loadSession ${resource.toString()} reason=${reason}${showReason === 'restore' ? ' restore' : ''}`); return undefined; }, + loadSession: async (resource: URI, reason?: string, showReason?: string) => { calls.push(`loadSession ${resource.toString()} reason=${reason}${showReason && showReason !== 'open' ? ` ${showReason}` : ''}`); return undefined; }, startNewLocalSession: async () => { calls.push('startNewLocalSession'); return undefined; }, focus: () => { calls.push('focus'); }, focusInput: () => { calls.push('focusInput'); }, - reportOpened: (resource: URI) => { calls.push(`reportOpened ${resource.toString()}`); }, - reportRestored: (resource: URI) => { calls.push(`reportRestored ${resource.toString()}`); }, + reportShown: (resource: URI, reason: string) => { calls.push(`reportShown ${resource.toString()} ${reason}`); }, beginSessionsListSuppression: () => { calls.push('suppress'); return toDisposable(() => calls.push('unsuppress')); }, }); const instantiationService = disposables.add(new TestInstantiationService()); @@ -73,6 +72,8 @@ suite('ChatViewHostService', () => { results.openSession = await service.openSession(resource, { sessionTypeSelectionReason: 'explicitOverride' }) === widget; // One to restore is not reported as opened await service.openSession(resource, { preserveFocus: true, restore: true }); + // The session that the first request of the chat it shows made is reported as such + await service.openSession(resource, { preserveFocus: true, commit: true }); results.toggle = await service.toggle(); const start = await service.startSession(resource, { local: false, importingConversation: true, beforeOpen: () => calls.push('beforeOpen') }); await start.moved(URI.parse('test:/moved')); @@ -87,8 +88,9 @@ suite('ChatViewHostService', () => { calls: [ `openView ${ChatViewId} focus=true`, `openView ${ChatViewId} focus=false`, - `openView ${ChatViewId} focus=true`, 'loadSession test:/session reason=explicitOverride', 'reportOpened test:/session', 'focusInput', - `openView ${ChatViewId} focus=false`, 'loadSession test:/session reason=undefined restore', 'reportRestored test:/session', + `openView ${ChatViewId} focus=true`, 'loadSession test:/session reason=explicitOverride', 'reportShown test:/session open', 'focusInput', + `openView ${ChatViewId} focus=false`, 'loadSession test:/session reason=undefined restore', 'reportShown test:/session restore', + `openView ${ChatViewId} focus=false`, 'loadSession test:/session reason=undefined commit', 'reportShown test:/session commit', `setPartHidden false ${Parts.AUXILIARYBAR_PART}`, `openView ${ChatViewId} focus=undefined`, 'beforeOpen', 'suppress', 'progress', 'loadSession test:/session reason=explicitOverride', 'focus', `openView ${ChatViewId} focus=undefined`, 'loadSession test:/moved reason=undefined', diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index 1aae367c7d6aa2..3d0ab1e601e7b2 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -20,6 +20,7 @@ import { NullLogService } from '../../../../../../../platform/log/common/log.js' import { AccountPolicyGateState } from '../../../../../../services/policies/common/accountPolicyService.js'; import { TestContextService, TestStorageService } from '../../../../../../test/common/workbenchTestServices.js'; import { ChatViewPane } from '../../../../browser/widgetHosts/viewPane/chatViewPane.js'; +import { IChatWidgetViewModelChangeEvent } from '../../../../browser/chat.js'; import { IChatViewHostShownSession } from '../../../../browser/chatViewHost.js'; import { ChatWidget } from '../../../../browser/widget/chatWidget.js'; import { mainWindow } from '../../../../../../../base/browser/window.js'; @@ -174,7 +175,7 @@ suite('ChatViewPane session transitions', () => { const disposed: URI[] = []; const locks: string[] = []; /** Tells the pane its widget's chat changed, as the widget's onDidChangeViewModel does. */ - let didChangeViewModel = () => { }; + let didChangeViewModel = (_committed?: boolean) => { }; const paneStore = store.add(new DisposableStore()); const modelRef = paneStore.add(new MutableDisposable()); const createReference = (model: IChatModel): IChatModelReference => Object.assign(toDisposable(() => { @@ -194,6 +195,11 @@ suite('ChatViewPane session transitions', () => { bindings.push(model?.sessionResource); didChangeViewModel(); }, + /** As its first request makes the session it sends in, which the widget then shows */ + showCommitted: (model: IChatModel) => { + shown = model; + didChangeViewModel(true); + }, lockToCodingAgent: (_name: string, _displayName: string, sessionType: string) => { locks.push(sessionType); }, unlockFromCodingAgent: () => { locks.push('local'); }, visible: true, @@ -233,7 +239,7 @@ suite('ChatViewPane session transitions', () => { agentSessionsService: { model: { getSession: () => undefined } }, updateActions: () => { }, }); - didChangeViewModel = () => (Reflect.get(pane, 'updateShownSession') as () => void).call(pane); + didChangeViewModel = committed => (Reflect.get(pane, 'onDidChangeShownModel') as (e: IChatWidgetViewModelChangeEvent) => void).call(pane, { previousSessionResource: undefined, currentSessionResource: shown?.sessionResource, committed }); return { pane, paneStore, widget, modelRef, nextModel, nextRef, bindings, disposed, locks }; } @@ -301,6 +307,12 @@ suite('ChatViewPane session transitions', () => { const reportedBefore = restored.pane.shownSession.get(); restored.pane.reportOpened(nextResource); const reportedAgain = restored.pane.shownSession.get() !== reportedBefore; + // Its first request makes a session of the new chat, which the widget then shows: not one the user picked + restored.widget.showCommitted(upcastPartial({ sessionResource: nextResource.with({ path: '/committed' }) })); + const committed = shown(restored); + // The user opens it, also as the chat it shows + restored.pane.reportOpened(nextResource.with({ path: '/committed' })); + const committedOpened = shown(restored); Object.assign(restored.pane, { chatService: { acquireOrLoadSession: async () => savedReference() } }); await restored.pane.loadSession(savedResource); const afterLoad = shown(restored); @@ -329,11 +341,13 @@ suite('ChatViewPane session transitions', () => { await loading; await restoration; - assert.deepStrictEqual({ afterRestore, otherReported, reopened, reportedAgain, afterLoad, loadedToRestore, reopenedToRestore, superseded: shown(superseded) }, { + assert.deepStrictEqual({ afterRestore, otherReported, reopened, reportedAgain, committed, committedOpened, afterLoad, loadedToRestore, reopenedToRestore, superseded: shown(superseded) }, { afterRestore: '/untitled-next restore', otherReported: '/untitled-next restore', reopened: '/untitled-next open', reportedAgain: true, + committed: '/committed commit', + committedOpened: '/committed open', afterLoad: '/saved open', loadedToRestore: '/recalled restore', reopenedToRestore: '/recalled restore', From fdeaddd9dd8dcf0919a1eee5cedc588ce3f8cfc2 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 06:23:45 +0100 Subject: [PATCH 052/165] Worktree support: recall the shown chat as restored without reloading it, and keep track only of remembered sessions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostWorktree.contribution.ts | 18 ++++++-- .../agentHost/agentHostWorktree.test.ts | 41 +++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts index 2ca84c1cc07875..f7b0b7f4c69c91 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts @@ -472,10 +472,13 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe } // The location the user picked again, and its chat, stay the most recent this._rememberSession(folder.uri, last); - if (!extUriBiasedIgnorePathCase.isEqual(last, this._shown?.resource)) { - // Shown as restored, so that it keeps the folder where the user moved it, also where it works elsewhere now - this._chatViewHostService.openSession(last, { preserveFocus: true, restore: true }).catch(error => this._logService.trace('[worktree] could not open the last chat of the folder\'s location', error)); - } + // Shown as restored, so that it keeps the folder where the user moved it, also where it works elsewhere now; the chat + // shown already is reported as restored without loading it again + const options = { preserveFocus: true, restore: true }; + const showing = extUriBiasedIgnorePathCase.isEqual(last, this._shown?.resource) + ? this._chatViewHostService.revealIfShowing(last, options) + : this._chatViewHostService.openSession(last, options); + showing.catch(error => this._logService.trace('[worktree] could not open the last chat of the folder\'s location', error)); } private _markTerminals(): void { @@ -578,6 +581,13 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe } private _storeLastSessions(): void { + // Only the remembered sessions are known to have been listed + const remembered = [...this._lastSessions.values()]; + for (const resource of [...this._listedSessions]) { + if (!remembered.some(candidate => extUriBiasedIgnorePathCase.isEqual(candidate, resource))) { + this._listedSessions.delete(resource); + } + } const entries = [...this._lastSessions].map(([known, resource]) => [known.toString(), resource.toString()]); this._storageService.store(LAST_SESSIONS_STORAGE_KEY, JSON.stringify(entries), StorageScope.WORKSPACE, StorageTarget.MACHINE); } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts index 85054f41b9437b..524be2f13b752b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts @@ -81,6 +81,12 @@ suite('AgentHostWorktree', () => { shownSession.set({ resource, reason: openOptions?.restore ? 'restore' : 'open' }, undefined); return undefined; }, + // Reports the chat it shows already as asked, without loading it again + revealIfShowing: async (resource: URI, openOptions?: { readonly restore?: boolean }) => { + opened.push(`${resource.path} (shown already${openOptions?.restore ? ', restore' : ''})`); + shownSession.set({ resource, reason: openOptions?.restore ? 'restore' : 'open' }, undefined); + return undefined; + }, })); instantiationService.stub(IChatSessionsService, upcastPartial({ onDidCommitSession: onDidCommitSession.event })); instantiationService.stub(IAgentSessionsService, upcastPartial({ @@ -451,4 +457,39 @@ suite('AgentHostWorktree', () => { assert.deepStrictEqual(feature.marks.at(-1), 'marked'); }); + + test('the last chat of where the user moves the folder, shown already before its working directory is known, keeps the folder there as that becomes known', async () => { + const feature = createWorktree({ enabled: true }); + feature.workingDirectories.set(agentSession('r').toString(), worktree.fsPath); + feature.show(agentSession('r')); + await settle(); + feature.show(localChat); + await settle(); + // Shown again before the sessions list knows where it works, such as after a reload + feature.workingDirectories.delete(agentSession('r').toString()); + feature.show(agentSession('r')); + await settle(); + feature.userMovesTo(worktree); + await settle(); + feature.workingDirectories.set(agentSession('r').toString(), created.fsPath); + feature.onDidChangeSessions.fire(); + await settle(); + + assert.deepStrictEqual({ moves: feature.moves, opened: feature.opened }, { + moves: ['/work/vscode.worktrees/feature', '/work/vscode'], + opened: ['/r (shown already, restore)'], + }); + }); + + test('keeps track only of the sessions it remembers', async () => { + const feature = createWorktree({ enabled: true, at: worktree }); + for (let i = 0; i < 25; i++) { + feature.workingDirectories.set(agentSession(`s${i}`).toString(), worktree.fsPath); + feature.show(agentSession(`s${i}`)); + await settle(); + } + const listed = Reflect.get(feature.contribution, '_listedSessions') as { readonly size: number }; + + assert.deepStrictEqual({ listed: listed.size, last: feature.contribution.getLastSession(worktree)?.path }, { listed: 1, last: '/s24' }); + }); }); From df54a17a0fcde4733587b4456f9fe70d4edcc7c9 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 06:24:14 +0100 Subject: [PATCH 053/165] Editor window sessions: retarget every composer of the window through the composer folders service IEditorWindowComposerFoldersService.registerComposer / retargetComposerFolders(to, from): the window's composers that follow their host to another folder, those of the chat part and of the Chat view alike, follow it there. The chat part registers its composers, and its retargetComposerFolders delegates to the service. Merges #348302 6e0d90b8d70. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentsView/agentsChatPart.ts | 11 +++---- .../editorWindowComposerFolders.ts | 29 +++++++++++++++++++ .../editorWindowComposerFolders.test.ts | 15 ++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts index 1cbb7e78cc605a..efb7bfb6eb2089 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts @@ -137,8 +137,8 @@ export interface IAgentsChatPartService { setComposerFolderOptions(provider: () => IAgentsChatPartComposerFolderOptions): IDisposable; /** - * Aims its composers that aim at `from`, the folder their host aimed them at, at `to`, such as the location the window's - * folder moved to: not those aiming at a folder the user picked, nor those that send (see `ChatWindowComposerModel.retargetFolder`). + * Aims the window's composers that aim at `from`, the folder their host aimed them at, at `to`, such as the location the + * window's folder moved to (see `IEditorWindowComposerFoldersService.retargetComposerFolders`). */ retargetComposerFolders(to: URI, from: URI): void; } @@ -303,9 +303,7 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart } retargetComposerFolders(to: URI, from: URI): void { - for (const composer of this._composerModels.values()) { - composer.retargetFolder(to, from); - } + this._composerFolders.retargetComposerFolders(to, from); } getComposerFolderOptions(): IAgentsChatPartComposerFolderOptions { @@ -531,6 +529,9 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart if (composer) { this._composerTabs.set(new Set([...this._composerTabs.get(), chat.input]), undefined); this._composerModels.set(chat.input, composer); + // It follows the window's folder with the window's other composers, until its tab disposes it + const registration = this._composerFolders.registerComposer(composer); + Event.once(composer.onWillDispose)(() => registration.dispose()); } // It stays in the chat part: its editor shows there chat.input.setMoveRestriction({ diff --git a/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowComposerFolders.ts b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowComposerFolders.ts index 8e016716e1e700..16b19e14cd9a05 100644 --- a/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowComposerFolders.ts +++ b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowComposerFolders.ts @@ -13,6 +13,11 @@ import { IChatWindowComposerInit } from '../chatEditor/chatWindowComposer.js'; /** The folders new-session composers create sessions in, besides the folder each aims at, and how they show them. */ export type IEditorWindowComposerFolderOptions = Pick; +/** A new-session composer that follows its host to another folder (see `ChatWindowComposerModel.retargetFolder`). */ +export interface IEditorWindowRetargetableComposer { + retargetFolder(folderUri: URI, from?: URI): boolean; +} + export const IEditorWindowComposerFoldersService = createDecorator('editorWindowComposerFoldersService'); /** @@ -28,12 +33,24 @@ export interface IEditorWindowComposerFoldersService { /** Has the composers offer the folders `provider` gives until the returned disposable is disposed; the one set last applies. */ setComposerFolderOptions(provider: () => IEditorWindowComposerFolderOptions): IDisposable; + + /** Counts `composer` among the window's composers that follow their host to another folder, until the returned disposable is disposed. */ + registerComposer(composer: IEditorWindowRetargetableComposer): IDisposable; + + /** + * Aims every registered composer still aimed at `from`, as its host aimed it, at `to`, such as the location the window's + * folder moved to: those of the chat part and of the Chat view alike. Not those aiming at a folder the user picked, nor + * those that send. + */ + retargetComposerFolders(to: URI, from: URI): void; } export class EditorWindowComposerFoldersService implements IEditorWindowComposerFoldersService { declare readonly _serviceBrand: undefined; + private readonly _composers = new Set<{ readonly composer: IEditorWindowRetargetableComposer }>(); + /** One per call of {@link setComposerFolderOptions}, also of the same provider. */ private readonly _providers: { readonly provider: () => IEditorWindowComposerFolderOptions }[] = []; private readonly _windowFolders: IObservable; @@ -46,6 +63,18 @@ export class EditorWindowComposerFoldersService implements IEditorWindowComposer return this._providers.at(-1)?.provider() ?? { allowedFolders: this._windowFolders }; } + registerComposer(composer: IEditorWindowRetargetableComposer): IDisposable { + const registration = { composer }; + this._composers.add(registration); + return toDisposable(() => this._composers.delete(registration)); + } + + retargetComposerFolders(to: URI, from: URI): void { + for (const { composer } of [...this._composers]) { + composer.retargetFolder(to, from); + } + } + setComposerFolderOptions(provider: () => IEditorWindowComposerFolderOptions): IDisposable { const registration = { provider }; this._providers.push(registration); diff --git a/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowComposerFolders.test.ts b/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowComposerFolders.test.ts index 44e235f2dae859..74a8eb1b740a6f 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowComposerFolders.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowComposerFolders.test.ts @@ -35,4 +35,19 @@ suite('EditorWindowComposerFoldersService', () => { assert.deepStrictEqual(steps, [['/repo'], ['/repo', '/repo.worktrees/a'], ['/repo', '/repo.worktrees/a'], ['/repo']]); }); + + test('retargets every registered composer, of the chat part and of the Chat view alike, until it is no longer registered', () => { + const service = new EditorWindowComposerFoldersService(upcastPartial({ onDidChangeWorkspaceFolders: Event.None, getWorkspace: () => upcastPartial({ folders: [] }) })); + const calls: string[] = []; + const composer = (name: string) => ({ retargetFolder: (to: URI, from?: URI) => { calls.push(`${name} ${from?.path} -> ${to.path}`); return true; } }); + + const chatPart = service.registerComposer(composer('chat part')); + const chatView = service.registerComposer(composer('chat view')); + service.retargetComposerFolders(URI.file('/repo.worktrees/a'), URI.file('/repo')); + chatPart.dispose(); + service.retargetComposerFolders(URI.file('/repo'), URI.file('/repo.worktrees/a')); + chatView.dispose(); + + assert.deepStrictEqual(calls, ['chat part /repo -> /repo.worktrees/a', 'chat view /repo -> /repo.worktrees/a', 'chat view /repo.worktrees/a -> /repo']); + }); }); From bb62ee6b005ef87da55954bf7c79869674765e6d Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 06:26:40 +0100 Subject: [PATCH 054/165] Worktree support: register the composer folders and retarget through the editor window's composer folders service, without the chat part Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostWorktreeComposerFolders.ts | 20 +++++++++---------- .../agentHostWorktreeComposerFolders.test.ts | 18 ++++++----------- 2 files changed, 15 insertions(+), 23 deletions(-) diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts index 14b9eda7f758a8..c2f7ffd5a13481 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts @@ -21,8 +21,7 @@ import { IWorkbenchEnvironmentService } from '../../../../services/environment/c import { ChatContextKeys } from '../../../chat/common/actions/chatContextKeys.js'; import { ChatConfiguration } from '../../../chat/common/constants.js'; import { IAllowedFolderPresentation } from '../chat/sessionWorkspacePicker.js'; -import { IAgentsChatPartService } from './agentsChatPart.js'; -import { IAgentsChatPartFrame } from './agentsChatPartFrame.js'; +import { IEditorWindowComposerFoldersService } from '../editorWindow/editorWindowComposerFolders.js'; /** * The folders the chat part's new-session composers create their sessions in: the window's folders and, for the one local @@ -146,16 +145,16 @@ export class AgentHostWorktreeComposerFolders extends Disposable { } /** - * While worktree support (`chat.editorWindow.enableWorktree`) and AI features are on, the chat part's new-session composers - * also offer the locations of the window's folder, and follow the folder as it moves, once the chat part exists. + * While worktree support (`chat.editorWindow.enableWorktree`) and AI features are on, the editor window's new-session + * composers, such as the chat part's and the Chat view's, also offer the locations of the window's folder, and follow the + * folder as it moves. Neither needs nor creates the chat part. */ export class AgentHostWorktreeComposerFoldersContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.agentHostWorktreeComposerFolders'; constructor( - @IAgentsChatPartFrame frame: IAgentsChatPartFrame, - @IAgentsChatPartService chatPart: IAgentsChatPartService, + @IEditorWindowComposerFoldersService composerFoldersService: IEditorWindowComposerFoldersService, @IConfigurationService configurationService: IConfigurationService, @IContextKeyService contextKeyService: IContextKeyService, @IInstantiationService instantiationService: IInstantiationService, @@ -167,19 +166,18 @@ export class AgentHostWorktreeComposerFoldersContribution extends Disposable imp } const settingEnabled = observableConfigValue(ChatConfiguration.EditorWindowEnableWorktree, false, configurationService); const aiEnabled = observableContextKey(ChatContextKeys.enabled.key, contextKeyService); - // Reads the chat part only once it exists, rather than creating it - const active = derived(reader => !!settingEnabled.read(reader) && !!aiEnabled.read(reader) && frame.contentCreated.read(reader)); + const active = derived(reader => !!settingEnabled.read(reader) && !!aiEnabled.read(reader)); this._register(autorun(reader => { if (!active.read(reader)) { return; } const folders = reader.store.add(instantiationService.createInstance(AgentHostWorktreeComposerFolders)); - reader.store.add(chatPart.setComposerFolderOptions(() => { + reader.store.add(composerFoldersService.setComposerFolderOptions(() => { folders.refresh(); return { allowedFolders: folders.folders, getFolderPresentation: folderUri => folders.getPresentation(folderUri) }; })); - // Its composers aimed at where the folder was aim at where it is now - reader.store.add(folders.onDidMoveFolder(({ from, to }) => chatPart.retargetComposerFolders(to, from))); + // The composers aimed at where the folder was aim at where it is now + reader.store.add(folders.onDidMoveFolder(({ from, to }) => composerFoldersService.retargetComposerFolders(to, from))); folders.refresh(); })); } diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts index 28e072aad9d302..ada14ff6620aa5 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts @@ -18,13 +18,11 @@ import { IConfigurationService } from '../../../../../../platform/configuration/ import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { MockContextKeyService } from '../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; -import { observableValue } from '../../../../../../base/common/observable.js'; import { toDisposable } from '../../../../../../base/common/lifecycle.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { ChatContextKeys } from '../../../../chat/common/actions/chatContextKeys.js'; import { ChatConfiguration } from '../../../../chat/common/constants.js'; -import { IAgentsChatPartComposerFolderOptions, IAgentsChatPartService } from '../../../browser/agentsView/agentsChatPart.js'; -import { IAgentsChatPartFrame } from '../../../browser/agentsView/agentsChatPartFrame.js'; +import { IEditorWindowComposerFolderOptions, IEditorWindowComposerFoldersService } from '../../../browser/editorWindow/editorWindowComposerFolders.js'; import { AgentHostWorktreeComposerFolders, AgentHostWorktreeComposerFoldersContribution } from '../../../browser/agentsView/agentHostWorktreeComposerFolders.js'; suite('AgentHostWorktreeComposerFolders', () => { @@ -192,15 +190,14 @@ suite('AgentHostWorktreeComposerFolders', () => { assert.deepStrictEqual(asked, ['/work/vscode', '/work/vscode']); }); - test('with worktree support and AI features on, once the chat part exists, its composers offer the folder\'s locations and follow the folder as it moves', async () => { + test('with worktree support and AI features on, the composers offer the folder\'s locations and follow the folder as it moves, without a chat part', async () => { const onDidChangeWorkspaceFolders = store.add(new Emitter()); let at = '/work/vscode'; const configurationService = new TestConfigurationService({ [ChatConfiguration.EditorWindowEnableWorktree]: true }); const contextKeyService = store.add(new MockContextKeyService()); contextKeyService.createKey(ChatContextKeys.enabled.key, true); - const contentCreated = observableValue('contentCreated', false); const calls: string[] = []; - let options: (() => IAgentsChatPartComposerFolderOptions) | undefined; + let options: (() => IEditorWindowComposerFolderOptions) | undefined; const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(IWorkspaceContextService, upcastPartial({ onDidChangeWorkspaceFolders: onDidChangeWorkspaceFolders.event, @@ -218,14 +215,12 @@ suite('AgentHostWorktreeComposerFolders', () => { instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IContextKeyService, contextKeyService); instantiationService.stub(IWorkbenchEnvironmentService, upcastPartial({ isSessionsWindow: false })); - instantiationService.stub(IAgentsChatPartFrame, upcastPartial({ contentCreated })); - instantiationService.stub(IAgentsChatPartService, upcastPartial({ + // No chat part: the service of the window's composers only + instantiationService.stub(IEditorWindowComposerFoldersService, upcastPartial({ setComposerFolderOptions: provider => { calls.push('options'); options = provider; return toDisposable(() => { calls.push('options cleared'); options = undefined; }); }, retargetComposerFolders: (to: URI, from: URI) => { calls.push(`retarget ${from.path} -> ${to.path}`); }, })); store.add(instantiationService.createInstance(AgentHostWorktreeComposerFoldersContribution)); - const beforeCreated = [...calls]; - contentCreated.set(true, undefined); await timeout(0); const offered = options?.().allowedFolders?.get().map(uri => uri.path); at = '/work/vscode.worktrees/a'; @@ -233,8 +228,7 @@ suite('AgentHostWorktreeComposerFolders', () => { configurationService.setUserConfiguration(ChatConfiguration.EditorWindowEnableWorktree, false); configurationService.onDidChangeConfigurationEmitter.fire({ affectsConfiguration: (key: string) => key === ChatConfiguration.EditorWindowEnableWorktree } as never); - assert.deepStrictEqual({ beforeCreated, offered, calls }, { - beforeCreated: [], + assert.deepStrictEqual({ offered, calls }, { offered: ['/work/vscode', '/work/vscode.worktrees/a'], calls: ['options', 'retarget /work/vscode -> /work/vscode.worktrees/a', 'options cleared'], }); From ee64ec290ce06e0bf22706ee665c2f2d20af60aa Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 06:27:17 +0100 Subject: [PATCH 055/165] Worktree infra: test mocks of the Chat view report shown chats through reportShown Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/test/browser/actions/chatMoveActions.test.ts | 2 +- .../chat/test/browser/widget/chatWidgetService.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatMoveActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatMoveActions.test.ts index 2c04a11d900ba8..c7c7c3901267e9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/actions/chatMoveActions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/actions/chatMoveActions.test.ts @@ -91,7 +91,7 @@ suite('Chat move actions', () => { return model; }, focusInput() { }, - reportOpened() { }, + reportShown() { }, }); const viewsService = upcastPartial({ openView: mockObject()().openView.resolves(view), diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatWidgetService.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatWidgetService.test.ts index ef9c2aa54f5400..e31ea418567b98 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatWidgetService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatWidgetService.test.ts @@ -218,7 +218,7 @@ suite('ChatWidgetService', () => { const oldWidget = createWidget(session); const emptyOldWidget = createWidget(undefined); const hostWidget = upcastPartial({}); - const view = { widget: oldWidget, focus: () => calls.push('view.focus'), loadSession: async () => { calls.push('view.loadSession'); }, reportOpened: () => calls.push('view.reportOpened') }; + const view = { widget: oldWidget, focus: () => calls.push('view.focus'), loadSession: async () => { calls.push('view.loadSession'); }, reportShown: (_resource: URI, reason: string) => calls.push(`view.reportShown ${reason}`) }; instantiationService.stub(IEditorGroupsService, { groups: [] }); instantiationService.stub(IViewsService, { getViewWithId: (() => view) as unknown as IViewsService['getViewWithId'], @@ -275,7 +275,7 @@ suite('ChatWidgetService', () => { const widget = await service.openSession(session); - assert.deepStrictEqual({ shown: widget === oldWidget, calls }, { shown: true, calls: [`openView ${ChatViewId}`, 'view.focus', 'view.reportOpened'] }); + assert.deepStrictEqual({ shown: widget === oldWidget, calls }, { shown: true, calls: [`openView ${ChatViewId}`, 'view.focus', 'view.reportShown open'] }); }); }); }); From 7ca8e3ffe50841261cd527c0b684b90a29dc4d0f Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 06:33:00 +0100 Subject: [PATCH 056/165] Dispose session settings schemas outside the provider's listing scope Use getAllSessions when the provider offers it, so schemas registered for sessions outside the listed scope are cleaned up when the provider goes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentSessionSettingsFileSystemProvider.ts | 3 +- ...tSessionSettingsFileSystemProvider.test.ts | 31 +++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/sessions/providers/agentHost/browser/agentSessionSettingsFileSystemProvider.ts b/src/vs/workbench/contrib/sessions/providers/agentHost/browser/agentSessionSettingsFileSystemProvider.ts index b53feea71a8376..3e2c5a7b87f88f 100644 --- a/src/vs/workbench/contrib/sessions/providers/agentHost/browser/agentSessionSettingsFileSystemProvider.ts +++ b/src/vs/workbench/contrib/sessions/providers/agentHost/browser/agentSessionSettingsFileSystemProvider.ts @@ -206,7 +206,8 @@ export class AgentSessionSettingsSchemaRegistrar extends AbstractMultiProviderAg } protected _targetsForProvider(provider: IAgentHostSessionsProvider): readonly ISession[] { - return provider.getSessions(); + // Also the sessions outside its listing scope, whose schemas are registered as they are read + return provider.getAllSessions?.() ?? provider.getSessions(); } protected _observeProvider( diff --git a/src/vs/workbench/contrib/sessions/providers/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts b/src/vs/workbench/contrib/sessions/providers/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts index eaafc7c5b5f758..24b3cde1f578e8 100644 --- a/src/vs/workbench/contrib/sessions/providers/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts +++ b/src/vs/workbench/contrib/sessions/providers/agentHost/test/browser/agentSessionSettingsFileSystemProvider.test.ts @@ -43,6 +43,7 @@ suite('AgentSessionSettingsFileSystemProvider', () => { readonly session: ISession; readonly uri: URI; readonly sessionProvider: IMockAgentHostSessionsProvider; + readonly removeProvider: () => void; } interface IMockAgentHostSessionsProvider extends IAgentHostSessionsProvider { @@ -56,6 +57,7 @@ suite('AgentSessionSettingsFileSystemProvider', () => { initialConfig: ResolveSessionConfigResult | undefined, registerProvider = true, sessionScoped = false, + outOfScope = false, ): ITestHarness { const session = createSession(); @@ -71,7 +73,9 @@ suite('AgentSessionSettingsFileSystemProvider', () => { replaceCalls, onDidChangeSessionConfig: onDidChangeSessionConfigEmitter.event, onDidChangeSessions: onDidChangeSessionsEmitter.event, - getSessions: () => [session], + // A session outside the provider's listing scope, such as of another folder, is not among its listed sessions + getSessions: () => outOfScope ? [] : [session], + getAllSessions: () => [session], getSessionOwnConfig: (_sessionId: string) => sessionProvider.config, replaceSessionConfig: async (sessionId: string, values: Record) => { replaceCalls.push({ sessionId, values }); @@ -105,7 +109,11 @@ suite('AgentSessionSettingsFileSystemProvider', () => { const schemaRegistrar = store.add(instantiationService.createInstance(AgentSessionSettingsSchemaRegistrar)); const fs = store.add(instantiationService.createInstance(AgentSessionSettingsFileSystemProvider, schemaRegistrar)); - return { fs, session, uri: agentSessionSettingsUri(session), sessionProvider }; + const removeProvider = () => { + registerProvider = false; + onDidChangeProvidersEmitter.fire({ added: [], removed: [sessionProvider as unknown as ISessionsProvider] }); + }; + return { fs, session, uri: agentSessionSettingsUri(session), sessionProvider, removeProvider }; } test('reads, watches and writes through the session-scoped runtime rather than the catalog', async () => { @@ -331,5 +339,24 @@ suite('AgentSessionSettingsFileSystemProvider', () => { assert.strictEqual(schemaRegistry.hasSchemaContent(schemaId), false); assert.strictEqual(schemaRegistry.getSchemaAssociations()[schemaId], undefined); }); + + test('the schema of a session outside its provider\'s listing scope is disposed as the provider goes', async () => { + const { fs, uri, session, removeProvider } = createHarness({ + schema: { + type: 'object', + properties: { + autoApprove: { type: 'string', title: 'Auto Approve', sessionMutable: true, enum: ['default'] }, + }, + }, + values: { autoApprove: 'default' }, + }, true, false, true); + const schemaId = expectedSchemaId(session); + + await fs.readFile(uri); + const registered = schemaRegistry.hasSchemaContent(schemaId); + removeProvider(); + + assert.deepStrictEqual({ registered, afterProviderGone: schemaRegistry.hasSchemaContent(schemaId), association: schemaRegistry.getSchemaAssociations()[schemaId] }, { registered: true, afterProviderGone: false, association: undefined }); + }); }); }); From 3daf79368efea49265b9c81b64f24a46732f1034 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 06:33:03 +0100 Subject: [PATCH 057/165] Offer Show Working Set from the Changes pill and show it once empty In inputs that show only the working set, bring the Changes pill back once the working set is empty, and add a Show Working Set context action to the Changes pill of the session toolbar. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostSessionInputPills.ts | 22 +++++++++++++------ .../agentHostSessionInputPills.test.ts | 20 +++++++++++++++++ .../contrib/sessions/browser/chat/chatView.ts | 1 + .../browser/chat/sessionChatInputToolbar.ts | 7 ++++++ 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts index c047c4fb6746b6..238fe4acf0b1de 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts @@ -6,7 +6,7 @@ import { getWindow } from '../../../../../../base/browser/dom.js'; import { status } from '../../../../../../base/browser/ui/aria/aria.js'; import { isAuxiliaryWindow } from '../../../../../../base/browser/window.js'; -import { toAction } from '../../../../../../base/common/actions.js'; +import { IAction, toAction } from '../../../../../../base/common/actions.js'; import { distinct } from '../../../../../../base/common/arrays.js'; import { disposableTimeout, raceTimeout, SequencerByKey } from '../../../../../../base/common/async.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; @@ -575,6 +575,8 @@ export class AgentHostSessionInputPills extends Disposable { /** Whether the session's changes show as the input's working set, or will once its host advertises them, in place of the Changes pill. */ readonly changesPillHidden: IObservable; + /** The actions of the context menu of the Changes pill, such as to show the session's changes as the working set again. */ + readonly getChangesPillContextMenuActions: () => IAction[]; constructor( private readonly _widget: ChatWidget, @@ -717,7 +719,6 @@ export class AgentHostSessionInputPills extends Disposable { const catalogue = creationCatalogue.read(reader); return !!catalogue && expiredCreationCatalogue.read(reader) !== catalogue && !workingSetEnabled.read(reader); }); - const changesPillHidden = this.changesPillHidden = derived(this, reader => workingSetEnabled.read(reader) || workingSetPending.read(reader)); const changesetTarget = derivedOpts({ owner: this, equalsFn: changesetTargetEquals }, reader => { const currentResolution = resolution.read(reader); const chat = chatResource.read(reader); @@ -824,6 +825,11 @@ export class AgentHostSessionInputPills extends Disposable { const diffs = changes.read(reader); return channel && review ? review.project(channel, diffs.map(diff => ({ diff, nonce: this._fileChangeNonces.get(diff) })), reader).map(file => file.diff) : diffs; }); + // The Changes pill hides while the working set shows the changes, and while a new session's catalogue is pending. An + // input that shows only the working set, whose Changes pill is the session's, shows the pill again once the working set + // shows no files, such as after Keep, so the session's other changes stay reachable. + const changesPillHidden = this.changesPillHidden = derived(this, reader => workingSetPending.read(reader) + || (workingSetEnabled.read(reader) && (!this.workingSetOnly || (workingSetChanges.read(reader)?.length ?? 0) > 0))); // Undo reverts a file's changes with the host's file-level Discard Changes operation, when the host offers it. const workingSetDiscard = derived(this, reader => { const kind = changesetTarget.read(reader)?.changeset.changeKind; @@ -878,6 +884,12 @@ export class AgentHostSessionInputPills extends Disposable { reader.store.add(this._widget.inputPart.registerSessionFileChanges(workingSetChanges, undo, workingSetKeep)); } })); + // The Changes pill, also the one an input that shows only the working set shows, offers to show the working set again + this.getChangesPillContextMenuActions = () => workingSetAvailable.get() && !workingSetConfigured.get() ? [toAction({ + id: 'chat.sessionWorkingSet.showWorkingSet', + label: localize('agentHostSessionPills.showWorkingSet', "Show Working Set"), + run: () => this._configurationService.updateValue(ChatConfiguration.EditorWorkingSetEnabled, true), + })] : []; if (this.workingSetOnly) { return; } @@ -1112,11 +1124,7 @@ export class AgentHostSessionInputPills extends Disposable { stats: changeStats, label: derived(this, reader => changesetTarget.read(reader)?.changeset.label ?? localize('agentHostSessionPills.changes', "Changes")), open: () => this._openChanges(changesetTarget.get()?.changeset, changes.get()), - getContextMenuPrimaryActions: () => workingSetAvailable.get() ? [toAction({ - id: 'chat.sessionWorkingSet.showWorkingSet', - label: localize('agentHostSessionPills.showWorkingSet', "Show Working Set"), - run: () => this._configurationService.updateValue(ChatConfiguration.EditorWorkingSetEnabled, true), - })] : [], + getContextMenuPrimaryActions: () => this.getChangesPillContextMenuActions(), }, pullRequests: createSessionPullRequestPillData(pullRequestSections, visibility.pullRequests, pullRequestIcon), issues: { sections: issueSections, icon: issueIcon }, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts index 674c0c4a7a452d..03eab813dc927f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts @@ -3327,6 +3327,26 @@ suite('AgentHostSessionInputPills', () => { }); }); + test('an input that shows only the working set shows the Changes pill again once the working set shows no files, and the pill offers to show the working set again', async () => { + const { harness, setOperations } = createSessionWorkingSetPills({ workingSetOnly: true }); + const operations = [acceptChanges(ChangesetOperationStatus.Idle), discardWorkingSetChanges(ChangesetOperationStatus.Idle)]; + setOperations(operations); + await harness.setWorkingSetEnabled(true); + const actions = () => harness.pills.getChangesPillContextMenuActions().map(action => action.label); + const withFiles = { hidden: harness.pills.changesPillHidden.get(), actions: actions() }; + // Keep empties the working set: the session's other changes are reachable through the Changes pill again + harness.connection.setState(StateComponents.Changeset, upcastPartial({ status: ChangesetStatus.Ready, files: [], operations })); + const afterKeep = { hidden: harness.pills.changesPillHidden.get(), actions: actions() }; + // Show Pill turned the working set off: the Changes pill offers to show it again + await harness.setWorkingSetEnabled(false); + + assert.deepStrictEqual({ withFiles, afterKeep, workingSetOff: { hidden: harness.pills.changesPillHidden.get(), actions: actions() } }, { + withFiles: { hidden: true, actions: [] }, + afterKeep: { hidden: false, actions: [] }, + workingSetOff: { hidden: false, actions: ['Show Working Set'] }, + }); + }); + test('opens, keeps and undoes Working Set files through the ambient host of a remote window', async () => { // The ambient host of a remote window resolves as `local` but runs on the remote, so its files aren't on this machine const resourceUris = createEditorRemoteAgentHostResourceUriMapper('wsl+debian', agentHostAuthority('vscode-remote://wsl+debian')); diff --git a/src/vs/workbench/contrib/sessions/browser/chat/chatView.ts b/src/vs/workbench/contrib/sessions/browser/chat/chatView.ts index c4114136933398..1de699bc38d6f3 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/chatView.ts +++ b/src/vs/workbench/contrib/sessions/browser/chat/chatView.ts @@ -707,6 +707,7 @@ export class ChatView extends AbstractChatView { if (options.input?.workingSet) { const workingSet = this._register(scopedInstantiationService.createInstance(AgentHostSessionWorkingSet, this._widget, 'auto')); this._chatPills.setChangesHidden(workingSet.changesPillHidden); + this._chatPills.setChangesContextMenuActions(workingSet.getChangesPillContextMenuActions); } this._register(this._widget.registerBackgroundActivityPillSections(this._chatPills.backgroundActivityPillSections)); this._register(this._widget.inputEditor.onKeyDown(event => { diff --git a/src/vs/workbench/contrib/sessions/browser/chat/sessionChatInputToolbar.ts b/src/vs/workbench/contrib/sessions/browser/chat/sessionChatInputToolbar.ts index 50aac7f4625e0c..29239a3cdc4138 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/sessionChatInputToolbar.ts +++ b/src/vs/workbench/contrib/sessions/browser/chat/sessionChatInputToolbar.ts @@ -421,6 +421,7 @@ export class SessionChatInputToolbar extends Disposable { private readonly _inputPills: ChatInputPills; /** Whether the Changes pill hides, such as while the input shows the session's changes as its working set. */ private readonly _changesHidden = observableValue>(this, constObservable(false)); + private _changesContextMenuActions: () => IAction[] = () => []; private _inputElement: HTMLElement | undefined; /** Sentinel distinguishing "no override" from an explicit `undefined` session. */ @@ -793,6 +794,7 @@ export class SessionChatInputToolbar extends Disposable { chatResource: target !== 'main' ? chat?.resource : undefined, }, target !== 'main' ? target.mainPart.activeGroup : undefined); }, + getContextMenuPrimaryActions: () => this._changesContextMenuActions(), }, pullRequests: pullRequestData, issues: { @@ -856,6 +858,11 @@ export class SessionChatInputToolbar extends Disposable { this._changesHidden.set(hidden, undefined); } + /** Adds `actions` to the context menu of the Changes pill, such as to show the session's changes as the working set again. */ + setChangesContextMenuActions(actions: () => IAction[]): void { + this._changesContextMenuActions = actions; + } + setChat(chat: IChat | undefined): void { this.setDebugData(undefined); this._sessionOverride.set('unset', undefined); From 7fba78b9600dea24d7ee3c5b9819b75f9ff01425 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 06:33:10 +0100 Subject: [PATCH 058/165] Keep folder-location sessions listed across workbench state changes Re-filter the agent host session list when the workbench state changes (Save Workspace As), and keep sessions whose metadata lists several roots when one of them matches a known folder location. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostSessionListStore.ts | 13 ++-- .../agentHostChatContribution.test.ts | 62 ++++++++++++++++++- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts index 1bd7a68aeed899..4437ae0c721e85 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts @@ -143,7 +143,7 @@ export class AgentHostSessionListStore extends Disposable { // folders changes, since filtering depends on it. The agent host itself // doesn't know which workspace this VS Code window has open. this._trackFolderLocation(); - this._register(this._workspaceContextService.onDidChangeWorkspaceFolders(() => { + const onDidChangeWorkspace = () => { if (this._options.filterToWorkspace === false) { return; } @@ -151,7 +151,11 @@ export class AgentHostSessionListStore extends Disposable { this._cacheValid = false; this._filterEntriesToWorkspace(); void this.refresh(CancellationToken.None); - })); + }; + this._register(this._workspaceContextService.onDidChangeWorkspaceFolders(onDidChangeWorkspace)); + // Also as the window's workspace changes without its folders, such as as Save Workspace As turns a folder into a + // workspace file: the locations its folder had are no longer its own + this._register(this._workspaceContextService.onDidChangeWorkbenchState(onDidChangeWorkspace)); } getSessions(provider: string): readonly IAgentHostSessionListEntry[] { @@ -553,8 +557,9 @@ export class AgentHostSessionListStore extends Disposable { return extUriBiasedIgnorePathCase.isEqual(URI.parse(multiRoot.workspaceFile), configuration); } // An empty window shows every session; a single-folder (or other - // non-multi-root) window falls back to working-directory containment. - return folders.length === 0 || this._matchesAnyFolder(workingDirectories, folders); + // non-multi-root) window falls back to working-directory containment, + // also in the locations its folder had, as for any other session. + return folders.length === 0 || this._matchesAnyFolder(workingDirectories, folders) || this._matchesAnyLocation(workingDirectories); } if (folders.length === 0) { return true; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 43db9f5d0c7253..8633563dfd2be4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -1107,7 +1107,8 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv const uri = workspaceFolders[index]; return uri ? { uri, name: uri.path, index, toResource: () => uri } : null; }, - onDidChangeWorkspaceFolders: Event.None + onDidChangeWorkspaceFolders: Event.None, + onDidChangeWorkbenchState: Event.None }); const trustController: { result: boolean | undefined; workspaceTrusted: boolean; workspaceTrustCalls: number; resourcesTrustCalls: number; resourcesTrustUris: URI[]; trustedUris: Set; setUrisTrustCalls: string[][] } = { result: true, workspaceTrusted: false, workspaceTrustCalls: 0, resourcesTrustCalls: 0, resourcesTrustUris: [], trustedUris: new Set(), setUrisTrustCalls: [] }; instantiationService.stub(IWorkspaceTrustRequestService, new class extends mock() { @@ -6432,6 +6433,7 @@ suite('AgentHostChatContribution', () => { getWorkspace: () => (testIWorkspace({ id: 'folder', folders: [{ uri: folder, name: 'repo', index: 0, toResource: () => folder }] })), getWorkspaceFolder: () => null, onDidChangeWorkspaceFolders: Event.None, + onDidChangeWorkbenchState: Event.None, }); const backendSession = AgentSession.uri('copilot', 'adopted-midflight'); @@ -7225,6 +7227,7 @@ suite('AgentHostChatContribution', () => { getWorkspace: () => (testIWorkspace({ id: '', folders: [{ uri: workspaceFolder, name: 'root', index: 0, toResource: () => workspaceFolder }] })), getWorkspaceFolder: () => null, onDidChangeWorkspaceFolders: Event.None, + onDidChangeWorkbenchState: Event.None, }); agentHostService.addSession({ session: AgentSession.uri('copilot', 'in-ws'), startTime: 1000, modifiedTime: 2000, summary: 'In workspace', workingDirectories: [URI.file('/workspace/root/sub')] }); @@ -7261,6 +7264,7 @@ suite('AgentHostChatContribution', () => { getWorkspace: () => (testIWorkspace({ id: '', folders: [...folders] })), getWorkspaceFolder: () => null, onDidChangeWorkspaceFolders: onDidChangeWorkspaceFolders.event, + onDidChangeWorkbenchState: Event.None, }); agentHostService.addSession({ session: AgentSession.uri('copilot', 'in-ws'), startTime: 1000, modifiedTime: 2000, summary: 'In workspace', workingDirectories: [URI.file('/workspace/root/sub')] }); @@ -7302,6 +7306,7 @@ suite('AgentHostChatContribution', () => { getWorkspace: () => testIWorkspace({ id: '', uri: primary, folders: [{ uri: folder, name: 'root', index: 0, toResource: () => folder }] }), getWorkspaceFolder: () => null, onDidChangeWorkspaceFolders: onDidChangeWorkspaceFolders.event, + onDidChangeWorkbenchState: Event.None, }); agentHostService.addSession({ session: AgentSession.uri('copilot', 'primary'), startTime: 1000, modifiedTime: 2000, summary: 'In the folder', workingDirectories: [URI.file('/workspace/root/sub')] }); @@ -7332,6 +7337,51 @@ suite('AgentHostChatContribution', () => { }); }); + test('a folder location switch keeps a session with multi-root metadata listed too, and Save Workspace As forgets the locations the folder had', async () => { + const { instantiationService, agentHostService } = createTestServices(disposables); + + const primary = URI.file('/workspace/root'); + const checkout = URI.file('/workspace/root.worktrees/feature'); + const workspaceFile = URI.file('/workspace/root.code-workspace'); + let folder = primary; + let configuration: URI | undefined = undefined; + const onDidChangeWorkspaceFolders = disposables.add(new Emitter<{ readonly added: never[]; readonly removed: never[]; readonly changed: never[] }>()); + const onDidChangeWorkbenchState = disposables.add(new Emitter()); + instantiationService.stub(IWorkspaceContextService, { + getWorkbenchState: () => configuration ? WorkbenchState.WORKSPACE : WorkbenchState.FOLDER, + getWorkspace: () => testIWorkspace({ id: '', uri: configuration ?? primary, configuration, folders: [{ uri: folder, name: 'root', index: 0, toResource: () => folder }] }), + getWorkspaceFolder: () => null, + onDidChangeWorkspaceFolders: onDidChangeWorkspaceFolders.event, + onDidChangeWorkbenchState: onDidChangeWorkbenchState.event, + }); + + agentHostService.addSession({ session: AgentSession.uri('copilot', 'primary'), startTime: 1000, modifiedTime: 2000, summary: 'In the folder', workingDirectories: [URI.file('/workspace/root/sub')] }); + agentHostService.addSession({ session: AgentSession.uri('copilot', 'multi-root'), startTime: 1000, modifiedTime: 2500, summary: 'Multi-root in the folder', workingDirectories: [URI.file('/workspace/root/sub')], _meta: withSessionMultiRootMetadata(undefined, { workspaceFile: URI.file('/workspace/other.code-workspace').toString() }) }); + agentHostService.addSession({ session: AgentSession.uri('copilot', 'checkout'), startTime: 1000, modifiedTime: 3000, summary: 'In the checkout', workingDirectories: [checkout] }); + + const listController = createSessionListController(disposables, instantiationService, agentHostService); + const labels = () => listController.items.map(item => item.label).sort(); + const moveTo = async (location: URI) => { + folder = location; + onDidChangeWorkspaceFolders.fire({ added: [], removed: [], changed: [] }); + await timeout(0); + }; + + await listController.refresh(CancellationToken.None); + await moveTo(checkout); + const atCheckout = labels(); + await moveTo(primary); + // Save Workspace As: the same folder in a workspace file, without a change of the folders + configuration = workspaceFile; + onDidChangeWorkbenchState.fire(WorkbenchState.WORKSPACE); + await timeout(0); + + assert.deepStrictEqual({ atCheckout, savedAsWorkspace: labels() }, { + atCheckout: ['In the checkout', 'In the folder', 'Multi-root in the folder'], + savedAsWorkspace: ['In the folder'], + }); + }); + test('workspace folder change re-filters the retained snapshot when refresh fails', async () => { const { instantiationService, agentHostService } = createTestServices(disposables); const workspaceFolder = URI.file('/workspace/root'); @@ -7342,6 +7392,7 @@ suite('AgentHostChatContribution', () => { getWorkspace: () => (testIWorkspace({ id: '', folders: [...folders] })), getWorkspaceFolder: () => null, onDidChangeWorkspaceFolders: onDidChangeWorkspaceFolders.event, + onDidChangeWorkbenchState: Event.None, }); agentHostService.addSession({ session: AgentSession.uri('copilot', 'in-ws'), startTime: 1000, modifiedTime: 2000, summary: 'In workspace', workingDirectories: [URI.file('/workspace/root/sub')] }); agentHostService.addSession({ session: AgentSession.uri('copilot', 'out-ws'), startTime: 1000, modifiedTime: 2000, summary: 'Outside workspace', workingDirectories: [URI.file('/other/place')] }); @@ -7383,6 +7434,7 @@ suite('AgentHostChatContribution', () => { getWorkspace: () => (testIWorkspace({ id: 'workspace', configuration, folders: folders.map((uri, index) => ({ uri, name: uri.path, index, toResource: () => uri })) })), getWorkspaceFolder: () => null, onDidChangeWorkspaceFolders: onDidChangeWorkspaceFolders.event, + onDidChangeWorkbenchState: Event.None, }); agentHostService.addSession({ @@ -7451,6 +7503,7 @@ suite('AgentHostChatContribution', () => { getWorkspace: () => (testIWorkspace({ id: 'empty', folders: [] })), getWorkspaceFolder: () => null, onDidChangeWorkspaceFolders: Event.None, + onDidChangeWorkbenchState: Event.None, }); agentHostService.addSession({ @@ -7484,6 +7537,7 @@ suite('AgentHostChatContribution', () => { getWorkspace: () => (testIWorkspace({ id: 'folder', folders: [{ uri: folder, name: 'a', index: 0, toResource: () => folder }] })), getWorkspaceFolder: () => null, onDidChangeWorkspaceFolders: Event.None, + onDidChangeWorkbenchState: Event.None, }); agentHostService.addSession({ @@ -7518,6 +7572,7 @@ suite('AgentHostChatContribution', () => { getWorkspace: () => (testIWorkspace({ id: 'folder', folders: [{ uri: folder, name: 'repo', index: 0, toResource: () => folder }] })), getWorkspaceFolder: () => null, onDidChangeWorkspaceFolders: Event.None, + onDidChangeWorkbenchState: Event.None, }); agentHostService.addSession({ @@ -7563,6 +7618,7 @@ suite('AgentHostChatContribution', () => { getWorkspace: () => (testIWorkspace({ id: 'folder', folders: [{ uri: folder, name: 'repo', index: 0, toResource: () => folder }] })), getWorkspaceFolder: () => null, onDidChangeWorkspaceFolders: Event.None, + onDidChangeWorkbenchState: Event.None, }); // Adoption drops `ehcliAdoptable` and leaves the durable adopted marker @@ -7592,6 +7648,7 @@ suite('AgentHostChatContribution', () => { getWorkspace: () => (testIWorkspace({ id: '', folders: [{ uri: workspaceFolder, name: 'root', index: 0, toResource: () => workspaceFolder }] })), getWorkspaceFolder: () => null, onDidChangeWorkspaceFolders: Event.None, + onDidChangeWorkbenchState: Event.None, }); const listController = createSessionListController(disposables, instantiationService, agentHostService); @@ -7766,6 +7823,7 @@ suite('AgentHostChatContribution', () => { getWorkspace: () => (testIWorkspace({ id: '', folders: [{ uri: workspaceFolder, name: 'root', index: 0, toResource: () => workspaceFolder }] })), getWorkspaceFolder: () => null, onDidChangeWorkspaceFolders: Event.None, + onDidChangeWorkbenchState: Event.None, }); const rebindCalls: { oldResource: URI; newResource: URI; provider: string }[] = []; @@ -7942,6 +8000,7 @@ suite('AgentHostChatContribution', () => { })), getWorkspaceFolder: () => null, onDidChangeWorkspaceFolders: Event.None, + onDidChangeWorkbenchState: Event.None, }); const rebindCalls: { oldResource: URI; newResource: URI }[] = []; @@ -7991,6 +8050,7 @@ suite('AgentHostChatContribution', () => { })) as unknown as IWorkspace, getWorkspaceFolder: () => null, onDidChangeWorkspaceFolders: Event.None, + onDidChangeWorkbenchState: Event.None, }; instantiationService.stub(IWorkspaceContextService, workspace as IWorkspaceContextService); const folders = disposables.add(new AgentHostNewSessionFolderService({ onDidDisposeSession: Event.None } as Partial as IChatService, workspace as IWorkspaceContextService, { extUri: extUriBiasedIgnorePathCase } as Partial as IUriIdentityService)); From 261fb0bd22b0defeed0d79af3313010fc0d7469e Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 06:37:20 +0100 Subject: [PATCH 059/165] Worktree support: test that a folder move retargets every composer registered with the editor window's service Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostWorktreeComposerFolders.test.ts | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts index ada14ff6620aa5..103212680cb870 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts @@ -22,7 +22,7 @@ import { toDisposable } from '../../../../../../base/common/lifecycle.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { ChatContextKeys } from '../../../../chat/common/actions/chatContextKeys.js'; import { ChatConfiguration } from '../../../../chat/common/constants.js'; -import { IEditorWindowComposerFolderOptions, IEditorWindowComposerFoldersService } from '../../../browser/editorWindow/editorWindowComposerFolders.js'; +import { EditorWindowComposerFoldersService, IEditorWindowComposerFolderOptions, IEditorWindowComposerFoldersService } from '../../../browser/editorWindow/editorWindowComposerFolders.js'; import { AgentHostWorktreeComposerFolders, AgentHostWorktreeComposerFoldersContribution } from '../../../browser/agentsView/agentHostWorktreeComposerFolders.js'; suite('AgentHostWorktreeComposerFolders', () => { @@ -233,4 +233,39 @@ suite('AgentHostWorktreeComposerFolders', () => { calls: ['options', 'retarget /work/vscode -> /work/vscode.worktrees/a', 'options cleared'], }); }); + + test('as the folder moves, every composer of the window registered with the service follows it, the chat part\'s and the Chat view\'s alike, without the chat part', async () => { + const onDidChangeWorkspaceFolders = store.add(new Emitter()); + let at = '/work/vscode'; + const contextKeyService = store.add(new MockContextKeyService()); + contextKeyService.createKey(ChatContextKeys.enabled.key, true); + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IWorkspaceContextService, upcastPartial({ + onDidChangeWorkspaceFolders: onDidChangeWorkspaceFolders.event, + getWorkspace: () => upcastPartial({ uri: URI.file('/work/vscode'), folders: [upcastPartial({ uri: URI.file(at) })] }), + })); + instantiationService.stub(IWorkspaceFolderLocationService, upcastPartial({ + onDidChangeProviders: Event.None, + onDidChangeLocation: Event.None, + onDidChangeCanSetLocation: onDidChangeWorkspaceFolders.event as Event as Event, + canSetLocation: () => true, + isAtAlternateLocation: folder => folder.uri.path !== '/work/vscode', + getLocation: () => undefined, + getLocations: async () => [], + })); + instantiationService.stub(IConfigurationService, new TestConfigurationService({ [ChatConfiguration.EditorWindowEnableWorktree]: true })); + instantiationService.stub(IContextKeyService, contextKeyService); + instantiationService.stub(IWorkbenchEnvironmentService, upcastPartial({ isSessionsWindow: false })); + const service = instantiationService.createInstance(EditorWindowComposerFoldersService); + instantiationService.stub(IEditorWindowComposerFoldersService, service); + const retargeted: string[] = []; + const composer = (name: string) => ({ retargetFolder: (folder: URI, from?: URI) => { retargeted.push(`${name}: ${from?.path} -> ${folder.path}`); return true; } }); + store.add(service.registerComposer(composer('chat part'))); + store.add(service.registerComposer(composer('Chat view'))); + store.add(instantiationService.createInstance(AgentHostWorktreeComposerFoldersContribution)); + at = '/work/vscode.worktrees/a'; + onDidChangeWorkspaceFolders.fire({ added: [], removed: [], changed: [] }); + + assert.deepStrictEqual(retargeted, ['chat part: /work/vscode -> /work/vscode.worktrees/a', 'Chat view: /work/vscode -> /work/vscode.worktrees/a']); + }); }); From 07cb7a63005a52ab23e0222fd2d098c45ba1f91f Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 06:39:17 +0100 Subject: [PATCH 060/165] chat: no claim from removed hosts; the composer takes focus as New Chat clears, cancels a restore, and stays hidden while the Chat view shows only sessions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../viewPane/chatViewHostService.ts | 4 ++ .../widgetHosts/viewPane/chatViewPane.ts | 16 +++-- .../viewPane/chatViewHostService.test.ts | 19 ++++++ .../widgetHosts/viewPane/chatViewPane.test.ts | 63 +++++++++++++++---- 4 files changed, 87 insertions(+), 15 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts index 4e18a3f9ec1177..b4e278b0b870c8 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts @@ -191,6 +191,10 @@ export class ChatViewHostService extends Disposable implements IChatViewHostServ /** Makes `host` the host that showed something last: a new chat of New Chat while `newChat`. */ private claim(host: IChatViewHost, newChat = false): void { + // A host no longer registered, such as one that completes a reveal after it was removed, shows nothing of the Chat view's + if (host !== this.chatViewHost && !this.registrations.some(registration => registration.host === host)) { + return; + } this.latest.set({ host, newChat }, undefined); this.updateChatViewTracking(); } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 052354c30bb990..ddda56f4d4c950 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import './media/chatViewPane.css'; -import { $, addDisposableListener, append, EventHelper, EventType, getWindow, setVisibility } from '../../../../../../base/browser/dom.js'; +import { $, addDisposableListener, append, EventHelper, EventType, getWindow, isAncestorOfActiveElement, setVisibility } from '../../../../../../base/browser/dom.js'; import { StandardMouseEvent } from '../../../../../../base/browser/mouseEvent.js'; import { IActionViewItem } from '../../../../../../base/browser/ui/actionbar/actionbar.js'; import { Button } from '../../../../../../base/browser/ui/button/button.js'; @@ -891,13 +891,19 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private providedComposer: IChatViewSessionsComposer | undefined; private composerShown = false; - /** Shows the provided composer in place of the chat while the chat is a new chat: empty, without a title. */ + /** + * Shows the provided composer in place of the chat while the chat is a new chat (empty, without a title), unless the + * Chat view shows only the sessions. As New Chat clears a chat with focus in the Chat view, focus moves to the composer. + */ private updateComposerVisibility(): void { - const shown = !!this.providedComposer && (!this._widget?.viewModel || (this._widget.isEmpty() && !this._widget.viewModel.model.title)); + const shown = !!this.providedComposer && !this.providedSessionsList?.sessionsOnly.get() && (!this._widget?.viewModel || (this._widget.isEmpty() && !this._widget.viewModel.model.title)); if (shown !== this.composerShown) { this.composerShown = shown; this.viewPaneContainer?.classList.toggle('shows-composer', shown); this.relayout(); + if (shown && this.element && isAncestorOfActiveElement(this.element)) { + this.providedComposer?.focus(); + } } } @@ -913,8 +919,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { this.providedComposer.newChat(); return false; } - // A chat still loading, which cleared the chat and so shows the composer, no longer replaces it + // A chat still loading or restoring, which cleared the chat and so shows the composer, no longer replaces it this.loadSessionCts.value?.cancel(); + this._applyModelCts.value?.cancel(); this.providedComposer.newChat(); this.providedComposer.focus(); return true; @@ -994,6 +1001,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { this._register(autorun(reader => { const sessionsOnly = provided.sessionsOnly.read(reader); this.viewPaneContainer?.classList.toggle('sessions-only', sessionsOnly); + this.updateComposerVisibility(); this.relayout(); })); } diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts index bec5284773423c..b0234cede20d36 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts @@ -325,4 +325,23 @@ suite('ChatViewHostService', () => { assert.deepStrictEqual(steps, ['/chat-view', '/chat-view', '/b', '/chat-view']); }); + + test('a host that completes a reveal after it is removed does not become the host whose shown chat the service reports', async () => { + const { service, shownSession, showView } = createService(); + shownSession.set({ resource: URI.parse('vscode-chat-session://local/chat-view'), reason: 'open' }, undefined); + showView(); + const revealed = new DeferredPromise(); + const registration = service.registerHost({ + ...upcastPartial({}), + shownSession: constObservable({ resource: URI.parse('agent-host-copilot:/removed'), reason: 'open' as const }), + reveal: () => revealed.p, + }); + const revealing = service.reveal(); + registration.dispose(); + await revealed.complete(undefined); + await revealing; + + assert.deepStrictEqual({ shown: service.shownSession.get()?.resource.path, replaced: service.replaced.get() }, { shown: '/chat-view', replaced: false }); + }); }); + diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index 53d4a502ec3d96..27f3bec577031d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -99,13 +99,16 @@ suite('ChatViewPane sessions refresh', () => { assert.deepStrictEqual(visibility, [true, false]); }); - test('a provided composer shows in place of the chat while the chat is a new chat, and never without one', () => { + test('a provided composer shows in place of the chat while the chat is a new chat, and never without one or while the Chat view shows only the sessions', () => { let empty = true; let title = ''; - const host: { providedComposer: object | undefined; composerShown: boolean; viewPaneContainer: HTMLElement; _widget: object; relayout(): void } = { - providedComposer: {}, + const sessionsOnly = observableValue('sessionsOnly', false); + const host: { providedComposer: object | undefined; providedSessionsList: { sessionsOnly: typeof sessionsOnly }; composerShown: boolean; viewPaneContainer: HTMLElement; element: HTMLElement; _widget: object; relayout(): void } = { + providedComposer: { focus: () => { } }, + providedSessionsList: { sessionsOnly }, composerShown: false, viewPaneContainer: document.createElement('div'), + element: document.createElement('div'), _widget: { get viewModel() { return { model: { title } }; }, isEmpty: () => empty }, relayout: () => { }, }; @@ -122,22 +125,60 @@ suite('ChatViewPane sessions refresh', () => { empty = true; title = ''; step(); + sessionsOnly.set(true, undefined); + step(); + sessionsOnly.set(false, undefined); const withoutComposer = { ...host, providedComposer: undefined, composerShown: false }; update.call(withoutComposer); - assert.deepStrictEqual({ states, withoutComposer: withoutComposer.composerShown }, { states: [true, false, true], withoutComposer: false }); + assert.deepStrictEqual({ states, withoutComposer: withoutComposer.composerShown }, { states: [true, false, true, false], withoutComposer: false }); }); - test('New Chat starts the provided composer over, and is the composer\'s while it shows in place of the chat', () => { + test('New Chat starts the provided composer over, and is the composer\'s while it shows in place of the chat, superseding a chat still loading or restoring', () => { const calls: string[] = []; const composer = { newChat: () => calls.push('newChat'), focus: () => calls.push('focus') }; - const newChat = Reflect.get(ChatViewPane.prototype, 'newChat') as (this: { providedComposer: typeof composer | undefined; composerShown: boolean; loadSessionCts: { value: undefined } }) => boolean; - const loadSessionCts = { value: undefined }; + const cancellable = (name: string) => ({ value: { cancel: () => calls.push(`cancel ${name}`) } }); + type Host = { providedComposer: typeof composer | undefined; composerShown: boolean; loadSessionCts: ReturnType; _applyModelCts: ReturnType }; + const newChat = Reflect.get(ChatViewPane.prototype, 'newChat') as (this: Host) => boolean; + const host = (providedComposer: typeof composer | undefined, composerShown: boolean): Host => ({ providedComposer, composerShown, loadSessionCts: cancellable('load'), _applyModelCts: cancellable('restore') }); const results = { - shown: newChat.call({ providedComposer: composer, composerShown: true, loadSessionCts }), - hidden: newChat.call({ providedComposer: composer, composerShown: false, loadSessionCts }), - withoutComposer: newChat.call({ providedComposer: undefined, composerShown: false, loadSessionCts }), + shown: newChat.call(host(composer, true)), + hidden: newChat.call(host(composer, false)), + withoutComposer: newChat.call(host(undefined, false)), + }; + assert.deepStrictEqual({ results, calls }, { results: { shown: true, hidden: false, withoutComposer: false }, calls: ['cancel load', 'cancel restore', 'newChat', 'focus', 'newChat'] }); + }); + + test('as New Chat clears a chat with focus in the Chat view, the composer that shows in its place takes focus', () => { + const focused: string[] = []; + const element = mainWindow.document.body.appendChild(document.createElement('div')); + const input = element.appendChild(document.createElement('textarea')); + let empty = false; + const host = { + providedComposer: { focus: () => focused.push('composer') }, + providedSessionsList: { sessionsOnly: constObservable(false) }, + composerShown: false, + viewPaneContainer: document.createElement('div'), + element, + _widget: { get viewModel() { return { model: { title: '' } }; }, isEmpty: () => empty }, + relayout: () => { }, }; - assert.deepStrictEqual({ results, calls }, { results: { shown: true, hidden: false, withoutComposer: false }, calls: ['newChat', 'focus', 'newChat'] }); + const update = Reflect.get(ChatViewPane.prototype, 'updateComposerVisibility') as (this: typeof host) => void; + try { + update.call(host); + input.focus(); + // New Chat clears the chat + empty = true; + update.call(host); + // Shown again, without focus in the Chat view + empty = false; + update.call(host); + input.blur(); + empty = true; + update.call(host); + } finally { + element.remove(); + } + assert.deepStrictEqual(focused, ['composer']); }); test('the Chat view takes the sessions list of the provider registered last, and its own once none is', () => { From 6c92a8117ddcbd5f9963a181a3abe3d41df95b3b Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 06:39:43 +0100 Subject: [PATCH 061/165] Worktree support: keeping the folder in place for a later chat supersedes only the earlier move, not that chat's switch Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostWorktree.contribution.ts | 21 +++++++++++++++--- .../agentHost/agentHostWorktree.test.ts | 22 ++++++++++++++++++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts index f7b0b7f4c69c91..51e5cf3d969c9f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts @@ -106,6 +106,8 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe private readonly _cts = new CancellationTokenSource(); /** Where it last moved the folder: that move is its own, not the user's. */ private _ownMove: URI | undefined; + /** Where it asked the workspace to keep the folder, to supersede a move under way: that is no move of someone else either. */ + private _keepInPlace: URI | undefined; private _shown: IShownChat | undefined; /** Waits for the shown session's working directory to become known or to change. */ @@ -200,8 +202,14 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe })); this._register(_folderLocationService.onDidChangeLocation(e => { this._foreground++; - const own = this._ownMove && extUriBiasedIgnorePathCase.isEqual(this._ownMove, e.folder.uri); - this._ownMove = undefined; + // Keeping the folder in place supersedes only the earlier move, not a later switch still under way + const keptInPlace = !!this._keepInPlace && extUriBiasedIgnorePathCase.isEqual(this._keepInPlace, e.folder.uri); + const own = keptInPlace || (!!this._ownMove && extUriBiasedIgnorePathCase.isEqual(this._ownMove, e.folder.uri)); + if (keptInPlace) { + this._keepInPlace = undefined; + } else { + this._ownMove = undefined; + } if (!own) { // Moved by someone else, such as the user in the window's title: no switch asked for before applies this._request++; @@ -445,7 +453,14 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe if (this._pendingMoves === 0 || !folder || !this._folderLocationService.canSetLocation(folder)) { return; } - this._workspaceEditingService.setFolderLocation(folder.uri).catch(error => this._logService.error('[worktree] could not keep the folder where it is', error)); + const keepInPlace = this._keepInPlace = folder.uri; + this._workspaceEditingService.setFolderLocation(folder.uri) + .catch(error => this._logService.error('[worktree] could not keep the folder where it is', error)) + .finally(() => { + if (this._keepInPlace === keepInPlace) { + this._keepInPlace = undefined; + } + }); } private _isAt(location: URI): boolean { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts index 524be2f13b752b..77ee519cd5d95d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts @@ -47,7 +47,7 @@ suite('AgentHostWorktree', () => { const created = URI.file('/work/vscode.worktrees/created'); /** The feature in a window whose folder is at `at`, recording the moves it asks for, the chats it opens and its experiment triggers. */ - function createWorktree(options: { readonly enabled: boolean; readonly at?: URI; readonly canSetLocation?: boolean; readonly worktrees?: readonly URI[]; readonly resolving?: Promise; readonly terminalCwd?: URI; readonly setting?: Promise }) { + function createWorktree(options: { readonly enabled: boolean; readonly at?: URI; readonly canSetLocation?: boolean; readonly worktrees?: readonly URI[]; readonly resolving?: Promise; readonly terminalCwd?: URI; readonly setting?: Promise; readonly resolvingFor?: Record> }) { const worktrees = options.worktrees ?? [worktree, created]; const marks: string[] = []; const terminal = upcastPartial({ @@ -109,6 +109,7 @@ suite('AgentHostWorktree', () => { getLocation: () => workspace.at.path === primary.path ? undefined : upcastPartial({ providerId: 'agentHost.sessionWorktrees' }), resolveLocation: async (_primary: URI, location: URI) => { await options.resolving; + await options.resolvingFor?.[location.path]; return worktrees.some(known => known.path === location.path) ? upcastPartial({ uri: location }) : undefined; }, registerProvider: () => { providers.push('registered'); return toDisposable(() => providers.push('unregistered')); }, @@ -492,4 +493,23 @@ suite('AgentHostWorktree', () => { assert.deepStrictEqual({ listed: listed.size, last: feature.contribution.getLastSession(worktree)?.path }, { listed: 1, last: '/s24' }); }); + + test('keeping the folder in place for a later chat supersedes only the earlier move, not that chat\'s switch still under way', async () => { + const setting = new DeferredPromise(); + const resolvingCreated = new DeferredPromise(); + const feature = createWorktree({ enabled: true, setting: setting.p, resolvingFor: { [created.path]: resolvingCreated.p } }); + feature.workingDirectories.set(agentSession('a').toString(), worktree.fsPath); + feature.workingDirectories.set(agentSession('b').toString(), created.fsPath); + feature.show(agentSession('a')); + await settle(); + feature.show(agentSession('b')); + await settle(); + setting.complete(); + await settle(); + resolvingCreated.complete(); + await settle(); + await settle(); + + assert.deepStrictEqual(feature.applied, ['/work/vscode', '/work/vscode.worktrees/created']); + }); }); From 62e5d2c3aedf3988cdf62ab30376672138902dd4 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 06:40:06 +0100 Subject: [PATCH 062/165] Chat part: mark a chat tab read through the sessions list, and show committed opens Run the sessions list's read-state commands from the agent sessions' Mark as Read and Mark as Unread for a chat tab's target in an editor window, registered while the chat part registers its actions. Open a session that IChatViewHostOpenOptions.commit asks for as committed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workbench/common/chatSessionCommands.ts | 8 ++++ .../agentSessions/agentSessionsActions.ts | 45 ++++++++++++++++++- .../browser/agentsView/agentsChatPart.ts | 8 ++-- .../agentsView/agentsChatPartActions.ts | 4 ++ .../browser/agentsView/agentsChatViewHost.ts | 2 +- .../browser/views/sessionsListActions.ts | 13 ++++++ .../browser/agentsView/agentsChatPart.test.ts | 6 ++- .../agentsView/agentsChatPartActions.test.ts | 37 ++++++++++++++- .../agentsView/agentsChatViewHost.test.ts | 9 ++-- 9 files changed, 119 insertions(+), 13 deletions(-) diff --git a/src/vs/workbench/common/chatSessionCommands.ts b/src/vs/workbench/common/chatSessionCommands.ts index 44291c2f6568d5..c0ad894f711ef1 100644 --- a/src/vs/workbench/common/chatSessionCommands.ts +++ b/src/vs/workbench/common/chatSessionCommands.ts @@ -5,3 +5,11 @@ export const AGENT_SESSION_MARK_READ_ACTION_ID = 'agentSession.markRead'; export const AGENT_SESSION_MARK_UNREAD_ACTION_ID = 'agentSession.markUnread'; + +/** + * Internal commands of the sessions list that mark what it shows, sessions or a chat of a session, read or unread. In a + * window that registers them, {@link AGENT_SESSION_MARK_READ_ACTION_ID} and {@link AGENT_SESSION_MARK_UNREAD_ACTION_ID} + * run them for a target that is not an agent session of the Chat view, such as one of the Sessions view. + */ +export const SESSIONS_LIST_MARK_READ_COMMAND_ID = '_sessionsList.markRead'; +export const SESSIONS_LIST_MARK_UNREAD_COMMAND_ID = '_sessionsList.markUnread'; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts index 18d32dd5395bd0..36f2b2c484b673 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts @@ -29,7 +29,8 @@ import { ChatConfiguration } from '../../common/constants.js'; import { ACTION_ID_NEW_CHAT } from '../actions/chatActions.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.js'; -import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { hasKey } from '../../../../../base/common/types.js'; import { IsSessionsWindowContext } from '../../../../common/contextkeys.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; @@ -41,7 +42,7 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../../pla import { IPaneCompositePartService } from '../../../../services/panecomposite/browser/panecomposite.js'; import { ChatSessionArchiveActionWording, getChatSessionArchiveActionPresentation } from '../../../../../platform/chat/common/sessionArchiveActions.js'; import { WorkbenchListFocusContextKey } from '../../../../../platform/list/browser/listService.js'; -import { AGENT_SESSION_MARK_READ_ACTION_ID, AGENT_SESSION_MARK_UNREAD_ACTION_ID } from '../../../../common/chatSessionCommands.js'; +import { AGENT_SESSION_MARK_READ_ACTION_ID, AGENT_SESSION_MARK_UNREAD_ACTION_ID, SESSIONS_LIST_MARK_READ_COMMAND_ID, SESSIONS_LIST_MARK_UNREAD_COMMAND_ID } from '../../../../common/chatSessionCommands.js'; import { AICustomizationManagementCommands } from '../../common/aiCustomizationWorkspaceService.js'; const AGENT_SESSIONS_CATEGORY = localize2('chatSessions', "Chat Agent Sessions"); @@ -482,6 +483,28 @@ abstract class BaseAgentSessionAction extends Action2 { abstract runWithSessions(sessions: IAgentSession[], accessor: ServicesAccessor): Promise | void; } +/** Whether `target` is what the sessions list of the sessions service shows: a session, or a chat of a session (`{ session, chat }`). */ +function isSessionsListTarget(target: unknown): boolean { + if (!target || typeof target !== 'object') { + return false; + } + const candidate = target as { session?: unknown; chat?: unknown; sessionId?: unknown; mainChat?: unknown }; + return hasKey(candidate, { session: true, chat: true }) || hasKey(candidate, { sessionId: true, mainChat: true }); +} + +/** + * Runs the sessions list's command that marks `context` read or unread, when the window registers it and `context` is what + * the sessions list shows, such as a session of the Sessions view or a chat of a chat tab; `undefined` otherwise. + */ +function runSessionsListReadState(accessor: ServicesAccessor, context: unknown, isRead: boolean): Promise | undefined { + const commandId = isRead ? SESSIONS_LIST_MARK_READ_COMMAND_ID : SESSIONS_LIST_MARK_UNREAD_COMMAND_ID; + const isTarget = Array.isArray(context) ? context.length > 0 && context.every(isSessionsListTarget) : isSessionsListTarget(context); + if (!isTarget || !CommandsRegistry.getCommand(commandId)) { + return undefined; + } + return accessor.get(ICommandService).executeCommand(commandId, context); +} + export class MarkAgentSessionUnreadAction extends BaseAgentSessionAction { protected override readonly supportsChildSessions = true; @@ -508,6 +531,15 @@ export class MarkAgentSessionUnreadAction extends BaseAgentSessionAction { }); } + override async run(accessor: ServicesAccessor, context?: IAgentSession | IMarshalledAgentSessionContext): Promise { + const listed = runSessionsListReadState(accessor, context, false); + if (listed) { + await listed; + } else { + await super.run(accessor, context); + } + } + runWithSessions(sessions: IAgentSession[]): void { for (const session of sessions) { session.setRead(false); @@ -541,6 +573,15 @@ export class MarkAgentSessionReadAction extends BaseAgentSessionAction { }); } + override async run(accessor: ServicesAccessor, context?: IAgentSession | IMarshalledAgentSessionContext): Promise { + const listed = runSessionsListReadState(accessor, context, true); + if (listed) { + await listed; + } else { + await super.run(accessor, context); + } + } + runWithSessions(sessions: IAgentSession[]): void { for (const session of sessions) { session.setRead(true); diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts index efb7bfb6eb2089..e4e8ade4564e92 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts @@ -36,7 +36,7 @@ import { ChatWindowLayoutMode } from '../../../chatWindow/common/chatWindowLayou import { ISessionsManagementService } from '../../common/sessionsManagement.js'; import { IChatEditorWindow, registerSessionChatViewEditor, SESSION_CHAT_VIEW_WORKING_SET_HOST_OPTIONS } from '../chatEditor/chatEditor.js'; import { ChatWindowComposerModel, ChatWindowComposers, createChatWindowComposerResource, isChatWindowComposerResource } from '../chatEditor/chatWindowComposer.js'; -import { IChatViewHostShownSession } from '../../../chat/browser/chatViewHost.js'; +import { getChatViewHostOpenReason, IChatViewHostShownSession } from '../../../chat/browser/chatViewHost.js'; import { IEditorWindowComposerFolderOptions, IEditorWindowComposerFoldersService } from '../editorWindow/editorWindowComposerFolders.js'; import { SessionChatEditorEntry, waitForSessionChat } from '../chatEditor/sessionChatEditorHost.js'; import { IChatViewFactory } from '../chatViewFactory.js'; @@ -71,6 +71,8 @@ export interface IAgentsChatPartOpenOptions { readonly newComposer?: boolean; /** Shows the chat as one the chat part restores, not one the user opens (see `IChatViewHostOpenOptions.restore`). */ readonly restore?: boolean; + /** Shows the chat as the session that its first send created, not one the user opens (see `IChatViewHostOpenOptions.commit`). */ + readonly commit?: boolean; } export const IAgentsChatPartService = createDecorator('agentsChatPartService'); @@ -492,10 +494,10 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart this._restoringTabs.delete(chat.input); await this._editorService.openEditor(chat.input, { pinned: true, preserveFocus: options?.preserveFocus }, group); this.composers.didOpen([chat.chat.get()]); - // Opened, also a tab it restored and shows already, such as one the user picks in a sessions list, unless shown as restored + // Opened, also a tab it restored and shows already, such as one the user picks in a sessions list, unless shown as restored or committed if (this._activeInput.get() === chat.input) { transaction(tx => { - this._activeReason.set(options?.restore ? 'restore' : 'open', tx); + this._activeReason.set(getChatViewHostOpenReason(options), tx); this._settleComposerTab(chat.input, tx); this._explicitOpens.set(this._explicitOpens.get() + 1, tx); }); diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartActions.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartActions.ts index eb3f06c111f771..7cb853e2484577 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartActions.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartActions.ts @@ -22,6 +22,7 @@ import { getSessionPresentationWorkspace, ISession } from '../../../../services/ import { INewSessionTarget } from '../parts/chatView.js'; import { SessionItemIsMultiSelectionContext } from '../../common/contextkeys.js'; import { ISessionSection, QUICK_CHATS_SECTION_ID, SessionItemContextMenuId, SessionSectionToolbarMenuId, SessionSectionTypeContext } from '../views/sessionsList.js'; +import { registerSessionsListReadStateCommands } from '../views/sessionsListActions.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { AGENT_HOST_ENABLED_CONTEXT_KEY } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; @@ -142,6 +143,9 @@ export function registerAgentsChatPartActions(): IDisposable { } })); + // What Mark as Read and Mark as Unread of a chat tab run in an editor window, whose agent sessions own their command ids + store.add(registerSessionsListReadStateCommands()); + return store; } diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts index 7b7408bbef175d..573d658196c7ef 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts @@ -127,7 +127,7 @@ export class AgentsChatViewHost implements IChatViewHost { if (!owner || generation !== this._generation) { return undefined; } - await this._chatPart.openChat(owner.session, owner.chat.resource, { preserveFocus: options?.preserveFocus, restore: options?.restore }); + await this._chatPart.openChat(owner.session, owner.chat.resource, { preserveFocus: options?.preserveFocus, restore: options?.restore, commit: options?.commit }); return await this._waitForWidget(owner.chat.resource); } finally { hold.dispose(); diff --git a/src/vs/workbench/contrib/sessions/browser/views/sessionsListActions.ts b/src/vs/workbench/contrib/sessions/browser/views/sessionsListActions.ts index 5e83b4ee86fdf5..e583e61dcdd23a 100644 --- a/src/vs/workbench/contrib/sessions/browser/views/sessionsListActions.ts +++ b/src/vs/workbench/contrib/sessions/browser/views/sessionsListActions.ts @@ -37,6 +37,7 @@ import { ISessionsListModelService } from '../sessionsListModelService.js'; import { ISessionComparisonService } from '../../common/sessionComparison.js'; import { IsWorkspaceGroupCappedContext, SessionsViewCompactContext, SessionsViewGroupingContext, SessionsViewId, SessionsView, SessionsViewSortingContext } from './sessionsView.js'; import { Menus, SessionHeaderMenus } from '../menus.js'; +import { SESSIONS_LIST_MARK_READ_COMMAND_ID, SESSIONS_LIST_MARK_UNREAD_COMMAND_ID } from '../../../../common/chatSessionCommands.js'; import { ISessionsManagementService } from '../../common/sessionsManagement.js'; import { ChatContextKeys } from '../../../chat/common/actions/chatContextKeys.js'; import { ChatSessionArchiveActionWording, ChatSessionArchiveActionWordingSettingId, getChatSessionArchiveActionPresentation, getChatSessionArchiveActionWording, SESSIONS_MARK_AS_DONE_CONFETTI_SETTING } from '../../../../../platform/chat/common/sessionArchiveActions.js'; @@ -662,6 +663,18 @@ async function markSessionListTargetsRead(accessor: ServicesAccessor, context: I } } +/** + * Registers {@link SESSIONS_LIST_MARK_READ_COMMAND_ID} and {@link SESSIONS_LIST_MARK_UNREAD_COMMAND_ID}, which mark what the + * sessions list and the chat tabs target read or unread, until the returned disposable is disposed. In an editor window, the + * agent sessions' Mark as Read and Mark as Unread run them for such a target, such as a chat tab of the chat part. + */ +export function registerSessionsListReadStateCommands(): IDisposable { + const store = new DisposableStore(); + store.add(CommandsRegistry.registerCommand(SESSIONS_LIST_MARK_READ_COMMAND_ID, (accessor, context?: ISession | ISession[] | ISessionChatItem) => markSessionListTargetsRead(accessor, context, true))); + store.add(CommandsRegistry.registerCommand(SESSIONS_LIST_MARK_UNREAD_COMMAND_ID, (accessor, context?: ISession | ISession[] | ISessionChatItem) => markSessionListTargetsRead(accessor, context, false))); + return store; +} + abstract class BaseUnarchiveActiveSessionAction extends Action2 { constructor(wording: ChatSessionArchiveActionWording) { diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts index 6ae8fff9f49acc..9b4c697de33db1 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts @@ -354,7 +354,7 @@ suite('AgentsChatPartService', () => { assert.deepStrictEqual(reports.slice(-2), ['open', 'open']); }); - test('a chat opened as restored shows as restored, and as opened once the user opens it', async () => { + test('a chat opened as restored or committed shows as such, and as opened once the user opens it', async () => { const restoredSession = buildTestSession({ id: 'restored', title: 'Restored' }); const main = restoredSession.mainChat.chat.resource; const { service } = createService({ sessions: () => [restoredSession.session] }); @@ -362,9 +362,11 @@ suite('AgentsChatPartService', () => { await service.openChat(restoredSession.session, main, { restore: true }); const restored = await shown(); + await service.openChat(restoredSession.session, main, { commit: true }); + const committed = await shown(); await service.openChat(restoredSession.session, main); - assert.deepStrictEqual({ restored, opened: await shown() }, { restored: 'main restore', opened: 'main open' }); + assert.deepStrictEqual({ restored, committed, opened: await shown() }, { restored: 'main restore', committed: 'main commit', opened: 'main open' }); }); test('its composers that aim at the folder their host aimed them at follow it to another folder, also the active one a new session reuses', async () => { diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPartActions.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPartActions.test.ts index 08699919521ca6..36cacca2a5b926 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPartActions.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPartActions.test.ts @@ -9,12 +9,15 @@ import { URI } from '../../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { isIMenuItem, MenuId, MenuRegistry } from '../../../../../../platform/actions/common/actions.js'; -import { CommandsRegistry } from '../../../../../../platform/commands/common/commands.js'; +import { CommandsRegistry, ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; +import { SESSIONS_LIST_MARK_READ_COMMAND_ID } from '../../../../../common/chatSessionCommands.js'; import { EditorInput } from '../../../../../common/editor/editorInput.js'; import { IEditorService } from '../../../../../services/editor/common/editorService.js'; import { ISession, ISessionWorkspace } from '../../../../../services/sessions/common/session.js'; +import { MarkAgentSessionReadAction, MarkAgentSessionUnreadAction } from '../../../../chat/browser/agentSessions/agentSessionsActions.js'; +import { ISessionsManagementService } from '../../../common/sessionsManagement.js'; import { getEditorWindowComposerTarget } from '../../../browser/editorWindow/editorWindowSessionsPresenter.js'; import { IAgentsChatPartOpenOptions, IAgentsChatPartService } from '../../../browser/agentsView/agentsChatPart.js'; import { registerAgentsChatPartActions, registerAgentsViewNewSessionActions } from '../../../browser/agentsView/agentsChatPartActions.js'; @@ -117,4 +120,36 @@ suite('Agents chat part actions', () => { none: target(undefined), }, { inSecond: '/work/tools', outside: '/work/vscode', none: '/work/vscode' }); }); + + test('Mark as Read and Mark as Unread of a chat tab mark its chat through the sessions list\'s commands, while the chat part registers its actions', async () => { + const built = buildTestSession({ id: 's1', title: 'Session' }); + const session = built.session; + const main = built.mainChat.chat; + const side = { ...main, resource: session.resource.with({ fragment: 'side' }) }; + const marked: string[] = []; + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(ISessionsManagementService, upcastPartial({ + markRead: async target => { marked.push(`read ${target.sessionId}`); }, + markUnread: async target => { marked.push(`unread ${target.sessionId}`); }, + markChatRead: async (_target, chat) => { marked.push(`read chat ${chat.resource.fragment}`); }, + markChatUnread: async (_target, chat) => { marked.push(`unread chat ${chat.resource.fragment}`); }, + })); + instantiationService.stub(ICommandService, upcastPartial({ + executeCommand: async (id: string, ...args: unknown[]) => instantiationService.invokeFunction(accessor => CommandsRegistry.getCommand(id)!.handler(accessor, ...args)) as R, + })); + const read = new MarkAgentSessionReadAction(); + const unread = new MarkAgentSessionUnreadAction(); + + const registration = registerAgentsChatPartActions(); + // A chat tab of a side chat and of a session's main chat run the agent sessions' commands + for (const [action, context] of [[read, { session, chat: side }], [unread, { session, chat: main }]] as const) { + await instantiationService.invokeFunction(accessor => action.run(accessor, context as never)); + } + registration.dispose(); + + assert.deepStrictEqual({ marked, registeredAfterDispose: !!CommandsRegistry.getCommand(SESSIONS_LIST_MARK_READ_COMMAND_ID) }, { + marked: ['read chat side', 'unread s1'], + registeredAfterDispose: false, + }); + }); }); diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts index 5ea563f2d1617e..48cd6b50bdbbf3 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts @@ -203,20 +203,21 @@ suite('AgentsChatViewHost', () => { }); }); - test('opens a session as restored when asked to, so that it does not report it as opened', async () => { + test('opens a session as restored or committed when asked to, so that it does not report it as opened', async () => { const chatResource = URI.parse('agent-host-copilot:/session-1'); const { owner, widget } = sessionOf(chatResource); - const opened: (boolean | undefined)[] = []; + const opened: string[] = []; const { host } = createHost({ - chatPart: { openChat: async (_session, _chat, openOptions) => { opened.push(openOptions?.restore); } }, + chatPart: { openChat: async (_session, _chat, openOptions) => { opened.push(`restore=${openOptions?.restore} commit=${openOptions?.commit}`); } }, sessionsManagementService: { getSessionForChatResource: () => owner }, widgets: [widget], }); await host.openSession(chatResource, { restore: true }); + await host.openSession(chatResource, { commit: true }); await host.openSession(chatResource); - assert.deepStrictEqual(opened, [true, undefined]); + assert.deepStrictEqual(opened, ['restore=true commit=undefined', 'restore=undefined commit=true', 'restore=undefined commit=undefined']); }); test('deactivated while it loads the session it starts, it releases the session and starts nothing', async () => { From ba3c2139f3f89fcf50cee00776252abc1b38188e Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 06:47:08 +0100 Subject: [PATCH 063/165] Chat view host: New Chat completes once the new chat shows, aimed at a target IChatViewHostService.newChat resolves once the host shows its new chat, so that a request that follows, such as Run Prompt in New Chat, goes to it rather than to the chat shown before. It takes the folder and session type a new chat aims at, which the editor window's default presenter passes to the composer that the Chat view shows, and the chat part to the composer it opens. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/actions/chatNewActions.ts | 10 ++++---- .../contrib/chat/browser/chatViewHost.ts | 17 ++++++++++---- .../viewPane/chatViewHostService.ts | 10 ++++---- .../widgetHosts/viewPane/chatViewPane.ts | 13 ++++++----- .../viewPane/chatViewSessionsList.ts | 8 +++++-- .../browser/actions/chatNewActions.test.ts | 23 ++++++++++++++++--- .../viewPane/chatViewHostService.test.ts | 16 ++++++------- .../browser/agentsView/agentsChatViewHost.ts | 14 +++++++---- .../editorWindowSessionsPresenter.ts | 11 +++++++-- .../agentsView/agentsChatViewHost.test.ts | 22 ++++++++++++++---- .../editorWindowSessionsRuntime.test.ts | 16 +++++++++---- 11 files changed, 113 insertions(+), 47 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts index e70560990e7b21..fdd678a13ccc70 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts @@ -114,15 +114,17 @@ export class NewChatAction extends Action2 { async function runNewChatCommand(accessor: ServicesAccessor, args: unknown[]): Promise { const actionContext = unwrapChatContextMenuActionContext(args[0]); const executeCommandContext = isNewEditSessionActionContext(actionContext) ? actionContext : undefined; + const chatViewHostService = accessor.get(IChatViewHostService); + const instantiationService = accessor.get(IInstantiationService); - // For the Chat view, such as from its title, a host that replaces it may start the new chat itself - if ((!actionContext || isChatViewTitleActionContext(actionContext)) && accessor.get(IChatViewHostService).newChat()) { + // For the Chat view, such as from its title, a host that replaces it may start the new chat itself: once it shows, so that + // a request that follows, such as of Run Prompt in New Chat, goes to it + if ((!actionContext || isChatViewTitleActionContext(actionContext)) && await chatViewHostService.newChat()) { return; } // Context from toolbar or lastFocusedWidget - const context = getEditingSessionContext(accessor, [actionContext, ...args.slice(1)]); - await runNewChatAction(accessor, context, executeCommandContext); + await instantiationService.invokeFunction(accessor => runNewChatAction(accessor, getEditingSessionContext(accessor, [actionContext, ...args.slice(1)]), executeCommandContext)); } export const NEW_CHAT_ICON_VARIANTS = [ diff --git a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts index a3d71979ce6426..2841dfecac1b16 100644 --- a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts +++ b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts @@ -54,6 +54,14 @@ export function getChatViewHostOpenReason(options: IChatViewHostOpenOptions | un return options?.commit ? 'commit' : options?.restore ? 'restore' : 'open'; } +/** What a new chat aims at, such as the folder and the session type of a new session that a sessions list starts. */ +export interface IChatViewNewChatTarget { + /** The folder the session of the new chat works in. */ + readonly folder?: URI; + readonly providerId?: string; + readonly sessionType?: string; +} + /** How {@link IChatViewHost.startSession} starts a session that its caller may send a first request to. */ export interface IChatViewHostSessionStartOptions { /** Whether the session is a new local chat, rather than the session of the given resource. */ @@ -104,10 +112,11 @@ export interface IChatViewHost { */ canShow?(sessionResource: URI): boolean; /** - * Starts a new chat in the host, as New Chat of the Chat view does, and returns whether it takes it. A host that does not - * implement it leaves New Chat to the chat widget it acts on. + * Starts a new chat in the host, as New Chat of the Chat view does, aimed at `target` when given, and resolves to whether + * it takes it once its new chat shows, so that a request that follows goes to it. A host that does not implement it leaves + * New Chat to the chat widget it acts on. */ - newChat?(): boolean; + newChat?(target?: IChatViewNewChatTarget): Promise; /** The chat the host shows; a host that does not implement it reports none. */ readonly shownSession?: IObservable; } @@ -121,7 +130,7 @@ export interface IChatViewHostService extends IChatViewHost { revealIfShowing(sessionResource: URI, options?: IChatViewHostOpenOptions): Promise; - newChat(): boolean; + newChat(target?: IChatViewNewChatTarget): Promise; /** Whether a registered host replaces the Chat view, which then shows only the chats the host leaves to it. */ readonly replaced: IObservable; diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts index 3ed87d637e1e12..d4c7ed5dceb60b 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts @@ -14,7 +14,7 @@ import { IViewDescriptorService, ViewContainerLocation } from '../../../../../co import { IWorkbenchLayoutService, Parts } from '../../../../../services/layout/browser/layoutService.js'; import { IViewsService } from '../../../../../services/views/common/viewsService.js'; import { ChatViewId, IChatWidget } from '../../chat.js'; -import { getChatViewHostOpenReason, IChatViewHost, IChatViewHostOpenOptions, IChatViewHostRevealOptions, IChatViewHostService, IChatViewHostSessionStart, IChatViewHostSessionStartOptions, IChatViewHostShownSession } from '../../chatViewHost.js'; +import { getChatViewHostOpenReason, IChatViewHost, IChatViewHostOpenOptions, IChatViewHostRevealOptions, IChatViewHostService, IChatViewHostSessionStart, IChatViewHostSessionStartOptions, IChatViewHostShownSession, IChatViewNewChatTarget } from '../../chatViewHost.js'; import { ChatViewPane } from './chatViewPane.js'; /** The Chat view, as the host of its chats. */ @@ -71,8 +71,8 @@ export class ChatViewPaneHost implements IChatViewHost { return chatView?.widget; } - newChat(): boolean { - return this.viewsService.isViewVisible(ChatViewId) && !!this.viewsService.getViewWithId(ChatViewId)?.newChat(); + async newChat(target?: IChatViewNewChatTarget): Promise { + return this.viewsService.isViewVisible(ChatViewId) && !!this.viewsService.getViewWithId(ChatViewId)?.newChat(target); } async toggle(): Promise { @@ -271,9 +271,9 @@ export class ChatViewHostService extends Disposable implements IChatViewHostServ return host.startSession(sessionResource, options); } - newChat(): boolean { + async newChat(target?: IChatViewNewChatTarget): Promise { const host = this.host; - const taken = host.newChat?.() ?? false; + const taken = await host.newChat?.(target) ?? false; if (taken) { this.claim(host, true); } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 0d987af9b3b35f..252cc583b961bd 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -72,7 +72,7 @@ import { AgentSessionsViewerOrientation, AgentSessionsViewerPosition } from '../ import { IChatViewSessionsComposer, IChatViewSessionsList, IChatViewSessionsListControl, IChatViewSessionsListService } from './chatViewSessionsList.js'; import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; import { CHAT_WIDGET_VIEW_STATE_CACHE_LIMIT, ChatViewId, IChatWidgetService, IChatWidgetViewModelChangeEvent, IChatWidgetViewState, setModelPreservingInputTypedWhileLoading } from '../../chat.js'; -import { IChatViewHostShownSession } from '../../chatViewHost.js'; +import { IChatViewHostShownSession, IChatViewNewChatTarget } from '../../chatViewHost.js'; import { IActivityService, ProgressBadge } from '../../../../../services/activity/common/activity.js'; import { DeferredPromise, disposableTimeout, raceCancellation, RunOnceScheduler } from '../../../../../../base/common/async.js'; import { AgentSessionsFilter, AgentSessionsGrouping } from '../../agentSessions/agentSessionsFilter.js'; @@ -902,20 +902,21 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } /** - * New Chat of the Chat view: the provided composer starts over, and takes New Chat while it shows in place of the chat. - * Returns whether it took it; otherwise New Chat clears the chat, in whose place the composer then shows. + * New Chat of the Chat view: the provided composer starts over, aimed at `target` when given, and takes New Chat while it + * shows in place of the chat. Returns whether it took it; otherwise New Chat clears the chat, in whose place the composer + * then shows. */ - newChat(): boolean { + newChat(target?: IChatViewNewChatTarget): boolean { if (!this.providedComposer) { return false; } if (!this.composerShown) { - this.providedComposer.newChat(); + this.providedComposer.newChat(target); return false; } // A chat still loading, which cleared the chat and so shows the composer, no longer replaces it this.loadSessionCts.value?.cancel(); - this.providedComposer.newChat(); + this.providedComposer.newChat(target); this.providedComposer.focus(); return true; } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewSessionsList.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewSessionsList.ts index b8075159f9ee3a..3f86377a998682 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewSessionsList.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewSessionsList.ts @@ -11,6 +11,7 @@ import { createDecorator, IInstantiationService } from '../../../../../../platfo import { IListStyles } from '../../../../../../base/browser/ui/list/listWidget.js'; import { IStyleOverride } from '../../../../../../platform/theme/browser/defaultStyles.js'; import { AgentSessionRefreshReason, IAgentSession } from '../../agentSessions/agentSessionsModel.js'; +import { IChatViewNewChatTarget } from '../../chatViewHost.js'; /** The sessions list that the Chat view shows in its sessions section: its own, or one that a provider gives it. */ export interface IChatViewSessionsListControl extends IDisposable { @@ -34,8 +35,11 @@ export interface IChatViewSessionsComposer extends IDisposable { readonly height: IObservable; layout(width: number, height: number): void; focus(): void; - /** Starts over with a new chat, as New Chat of the Chat view does: without the input, the attachments and the target it had. */ - newChat(): void; + /** + * Starts over with a new chat, as New Chat of the Chat view does: without the input, the attachments and the target it had, + * aimed at `target` when given, such as the folder and the session type of a new session that a sessions list starts. + */ + newChat(target?: IChatViewNewChatTarget): void; } /** A sessions list of the Chat view, with how the Chat view shows it. */ diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatNewActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatNewActions.test.ts index 2b3713ee1c5e22..5d00ded7079f22 100644 --- a/src/vs/workbench/contrib/chat/test/browser/actions/chatNewActions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/actions/chatNewActions.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; import { KeyCode, KeyMod } from '../../../../../../base/common/keyCodes.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { ContextKeyValue, IContext } from '../../../../../../platform/contextkey/common/contextkey.js'; @@ -11,7 +12,7 @@ import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { ChatAgentLocation } from '../../../common/constants.js'; import { NEW_CHAT_ICON_VARIANTS, NewChatAction, newChatIconVariantAction } from '../../../browser/actions/chatNewActions.js'; import { IChatViewHostService } from '../../../browser/chatViewHost.js'; -import { ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; import { upcastPartial } from '../../../../../../base/test/common/mock.js'; function context(values: Record): IContext { @@ -55,8 +56,8 @@ suite('Chat new actions', () => { let started = 0; const accessor = upcastPartial({ get: ((id: unknown) => { - assert.strictEqual(id, IChatViewHostService); - return { newChat: () => { started++; return true; } } as T; + assert.ok(id === IChatViewHostService || id === IInstantiationService); + return { newChat: async () => { started++; return true; } } as T; }) as ServicesAccessor['get'], }); for (const action of [new NewChatAction(), ...NEW_CHAT_ICON_VARIANTS.map(variant => new (newChatIconVariantAction(variant))())]) { @@ -65,4 +66,20 @@ suite('Chat new actions', () => { assert.strictEqual(started, 1 + NEW_CHAT_ICON_VARIANTS.length); }); + + test('New Chat completes only once the host that replaces the Chat view shows its new chat, so that a prompt that follows goes to it', async () => { + const shows = new DeferredPromise(); + const steps: string[] = []; + const accessor = upcastPartial({ + get: (() => ({ newChat: () => shows.p }) as T) as ServicesAccessor['get'], + }); + + const run = new NewChatAction().run(accessor).then(() => steps.push('New Chat completes')); + await timeout(0); + steps.push('host shows its new chat'); + shows.complete(true); + await run; + + assert.deepStrictEqual(steps, ['host shows its new chat', 'New Chat completes']); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts index 6d7c915a3464aa..9658bbde02fa69 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts @@ -162,16 +162,16 @@ suite('ChatViewHostService', () => { }); }); - test('New Chat is left to the chat widget, unless the host that replaces the Chat view starts it', () => { + test('New Chat is left to the chat widget, unless the host that replaces the Chat view starts it', async () => { const { service } = createService(); let started = 0; const withoutNewChat = service.registerHost(upcastPartial({})); - const results = [service.newChat()]; - const withNewChat = service.registerHost(upcastPartial({ newChat: () => { started++; return true; } })); - results.push(service.newChat()); + const results = [await service.newChat()]; + const withNewChat = service.registerHost(upcastPartial({ newChat: async () => { started++; return true; } })); + results.push(await service.newChat()); withNewChat.dispose(); withoutNewChat.dispose(); - results.push(service.newChat()); + results.push(await service.newChat()); assert.deepStrictEqual({ results, started }, { results: [false, true, false], started: 1 }); }); @@ -277,7 +277,7 @@ suite('ChatViewHostService', () => { canShow: resource => resource.scheme !== localChat.scheme, openSession: async () => undefined, reveal: async () => undefined, - newChat: () => takesNewChat, + newChat: async () => takesNewChat, }); const steps: string[] = []; await service.openSession(localChat); @@ -287,10 +287,10 @@ suite('ChatViewHostService', () => { steps.push(shown()); await service.openSession(localChat); // New Chat that the host does not take changes nothing - service.newChat(); + await service.newChat(); steps.push(shown()); takesNewChat = true; - service.newChat(); + await service.newChat(); steps.push(shown()); // The new chat starts a session hostShown.set({ resource: agentHostChat.with({ path: '/started' }), reason: 'open' }, undefined); diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts index 573d658196c7ef..710be9c50ea65e 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts @@ -5,7 +5,7 @@ import { raceTimeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { CancellationError, onUnexpectedError } from '../../../../../base/common/errors.js'; +import { CancellationError } from '../../../../../base/common/errors.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { derived, IObservable } from '../../../../../base/common/observable.js'; import { isEqual } from '../../../../../base/common/resources.js'; @@ -16,7 +16,7 @@ import { INotificationService } from '../../../../../platform/notification/commo import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IWorkbenchLayoutService, Parts } from '../../../../services/layout/browser/layoutService.js'; import { IChatWidget, IChatWidgetService } from '../../../chat/browser/chat.js'; -import { IChatViewHost, IChatViewHostOpenOptions, IChatViewHostSessionStart, IChatViewHostSessionStartOptions, IChatViewHostShownSession } from '../../../chat/browser/chatViewHost.js'; +import { IChatViewHost, IChatViewHostOpenOptions, IChatViewHostSessionStart, IChatViewHostSessionStartOptions, IChatViewHostShownSession, IChatViewNewChatTarget } from '../../../chat/browser/chatViewHost.js'; import { IChatService } from '../../../chat/common/chatService/chatService.js'; import { isAgentHostSessionResource, localChatSessionType } from '../../../chat/common/chatSessionsService.js'; import { ChatAgentLocation } from '../../../chat/common/constants.js'; @@ -88,12 +88,16 @@ export class AgentsChatViewHost implements IChatViewHost { return type !== localChatSessionType && this._sessionsManagementService.getAllSessionTypes().some(sessionType => (sessionType.chatSessionType ?? sessionType.id) === type); } - /** New Chat, such as + in the title of the Chat view, opens a new-session composer in the chat part, unless the Chat view keeps it. */ - newChat(): boolean { + /** + * New Chat, such as + in the title of the Chat view, opens a new-session composer in the chat part, unless the Chat view + * keeps it. Resolves once the composer shows, so that a request that follows goes to it, not to the chat shown before. + */ + async newChat(target?: IChatViewNewChatTarget): Promise { if (!this._ownsNewChat.get() || !this._available) { return false; } - this._chatPart.openComposer(this._composerTarget(), { newComposer: true }).catch(onUnexpectedError); + const aimed = target ? { folderUri: target.folder, providerId: target.providerId, sessionTypeId: target.sessionType } : this._composerTarget(); + await this._chatPart.openComposer(aimed, { newComposer: true }); return true; } diff --git a/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsPresenter.ts b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsPresenter.ts index e662e5ef0f3616..4ef82ae286570c 100644 --- a/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsPresenter.ts +++ b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsPresenter.ts @@ -101,10 +101,17 @@ class ChatViewSessionsPresenter implements IEditorWindowSessionsPresenter { await this._chatViewHostService.openSession(chatResource ?? session.mainChat.get().resource, { preserveFocus: options?.preserveFocus }); } - /** A new chat in the Chat view, as its New Chat starts one: the next request does not go to the chat it showed. */ - async openComposer(_target: INewSessionTarget | undefined, options?: IEditorWindowPresenterOpenOptions): Promise { + /** + * A new chat in the Chat view, as its New Chat starts one: the next request does not go to the chat it showed. The + * composer that the Chat view then shows, such as the one of its sessions list, is aimed at `target`. + */ + async openComposer(target: INewSessionTarget | undefined, options?: IEditorWindowPresenterOpenOptions): Promise { await this._chatViewHostService.reveal(options?.preserveFocus); await this._commandService.executeCommand(ACTION_ID_NEW_CHAT); + // Aimed only after New Chat, which starts its composer over without a target + if (target) { + await this._chatViewHostService.newChat({ folder: target.folderUri, providerId: target.providerId, sessionType: target.sessionTypeId }); + } } async closeChat(): Promise { } diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts index 48cd6b50bdbbf3..1abf4f723cf49f 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts @@ -300,15 +300,15 @@ suite('AgentsChatViewHost', () => { }, { agentHost: true, knownChat: true, knownSession: true, untitledOfOfferedType: true, local: false, extension: false }); }); - test('New Chat opens a new-session composer in the chat part, unless chat is hidden or disabled, or the Chat view keeps its New Chat', () => { + test('New Chat opens a new-session composer in the chat part, unless chat is hidden or disabled, or the Chat view keeps its New Chat', async () => { const available = createHost(); const unavailable = createHost({ chatAvailable: false }); const notOwned = createHost({ ownsNewChat: false }); assert.deepStrictEqual({ - available: available.host.newChat(), availableCalls: available.calls, - unavailable: unavailable.host.newChat(), unavailableCalls: unavailable.calls, - notOwned: notOwned.host.newChat(), notOwnedCalls: notOwned.calls, + available: await available.host.newChat(), availableCalls: available.calls, + unavailable: await unavailable.host.newChat(), unavailableCalls: unavailable.calls, + notOwned: await notOwned.host.newChat(), notOwnedCalls: notOwned.calls, }, { available: true, availableCalls: ['openComposer sessionType=undefined newComposer=true'], unavailable: false, unavailableCalls: [], @@ -316,6 +316,20 @@ suite('AgentsChatViewHost', () => { }); }); + test('New Chat resolves only once its composer shows, so that a prompt run in a new chat goes to it, not to the chat shown before', async () => { + const opening = new DeferredPromise(); + const steps: string[] = []; + const { host } = createHost({ chatPart: { openComposer: async () => { await opening.p; steps.push('composer shows'); } } }); + + const newChat = host.newChat().then(taken => steps.push(`New Chat resolves ${taken}`)); + await timeout(0); + steps.push('composer opening'); + opening.complete(); + await newChat; + + assert.deepStrictEqual(steps, ['composer opening', 'composer shows', 'New Chat resolves true']); + }); + test('reports the chat of the chat part\'s active tab only once the chat part exists, without creating it', () => { const contentCreated = observableValue('contentCreated', false); const shown: IChatViewHostShownSession = { resource: URI.parse('agent-host-copilot:/session-1'), reason: 'restore' }; diff --git a/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts b/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts index fe91e56907c4ea..c0a0558fdd5aad 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts @@ -119,16 +119,24 @@ suite('EditorWindowSessionsPresenterService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('without a presenter, a new session starts a new chat in the Chat view, rather than the next request going to the chat it showed', async () => { + test('without a presenter, a new session starts a new chat in the Chat view, rather than the next request going to the chat it showed, aimed at its target', async () => { const calls: string[] = []; const service = disposables.add(new EditorWindowSessionsPresenterService( - upcastPartial({ reveal: async preserveFocus => { calls.push(`reveal preserveFocus=${preserveFocus}`); return undefined; } }), + upcastPartial({ + reveal: async preserveFocus => { calls.push(`reveal preserveFocus=${preserveFocus}`); return undefined; }, + newChat: async target => { calls.push(`newChat ${target?.folder?.path} provider=${target?.providerId} type=${target?.sessionType}`); return true; }, + }), upcastPartial({ executeCommand: async (id: string) => { calls.push(id); return undefined; } }), )); await service.presenter.get().openComposer(undefined, { preserveFocus: true }); + const withoutTarget = calls.splice(0); + await service.presenter.get().openComposer({ folderUri: URI.file('/repo.worktrees/a'), providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }); - assert.deepStrictEqual(calls, ['reveal preserveFocus=true', ACTION_ID_NEW_CHAT]); + assert.deepStrictEqual({ withoutTarget, withTarget: calls }, { + withoutTarget: ['reveal preserveFocus=true', ACTION_ID_NEW_CHAT], + withTarget: ['reveal preserveFocus=undefined', ACTION_ID_NEW_CHAT, 'newChat /repo.worktrees/a provider=local-agent-host type=copilotcli'], + }); }); test('the presenter registered last presents, until its registration is disposed, also when the same presenter is registered twice', () => { @@ -175,7 +183,7 @@ suite('EditorWindowSessionsService', () => { test('New Session starts a new chat in the Chat view while no feature presents the sessions, and a composer aimed at its target while one does', async () => { const calls: string[] = []; const instantiationService = disposables.add(new TestInstantiationService()); - instantiationService.stub(IChatViewHostService, upcastPartial({ reveal: async () => { calls.push('reveal'); return undefined; } })); + instantiationService.stub(IChatViewHostService, upcastPartial({ reveal: async () => { calls.push('reveal'); return undefined; }, newChat: async () => { calls.push('newChat'); return true; } })); instantiationService.stub(ICommandService, upcastPartial({ executeCommand: async (id: string) => { calls.push(id); return undefined; } })); const presenterService = disposables.add(instantiationService.createInstance(EditorWindowSessionsPresenterService)); instantiationService.stub(IEditorWindowSessionsPresenterService, presenterService); From 401dc3be24b804533782304a843517d6125666f9 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 06:51:50 +0100 Subject: [PATCH 064/165] chat: New Chat of the Chat view resolves once the new chat shows, aimed at a target; the sessions-only state of the provided list From #348309 (fa880971e20, ba3c2139f3f), where they were made against this seam: - IChatViewHost.newChat(target?) resolves to whether the host takes New Chat once its new chat shows, so that a request that follows goes to it; IChatViewNewChatTarget names the folder and session type it aims at. - IChatViewSessionsListService.sessionsOnly reports whether the Chat view shows only the sessions of the provided list it shows (setActiveList). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/actions/chatNewActions.ts | 10 +++--- .../contrib/chat/browser/chatViewHost.ts | 17 +++++++--- .../viewPane/chatViewHostService.ts | 10 +++--- .../widgetHosts/viewPane/chatViewPane.ts | 14 +++++---- .../viewPane/chatViewSessionsList.ts | 28 +++++++++++++++-- .../browser/actions/chatNewActions.test.ts | 23 ++++++++++++-- .../viewPane/chatViewHostService.test.ts | 16 +++++----- .../viewPane/chatViewSessionsList.test.ts | 31 +++++++++++++++++++ 8 files changed, 116 insertions(+), 33 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewSessionsList.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts index e70560990e7b21..fdd678a13ccc70 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts @@ -114,15 +114,17 @@ export class NewChatAction extends Action2 { async function runNewChatCommand(accessor: ServicesAccessor, args: unknown[]): Promise { const actionContext = unwrapChatContextMenuActionContext(args[0]); const executeCommandContext = isNewEditSessionActionContext(actionContext) ? actionContext : undefined; + const chatViewHostService = accessor.get(IChatViewHostService); + const instantiationService = accessor.get(IInstantiationService); - // For the Chat view, such as from its title, a host that replaces it may start the new chat itself - if ((!actionContext || isChatViewTitleActionContext(actionContext)) && accessor.get(IChatViewHostService).newChat()) { + // For the Chat view, such as from its title, a host that replaces it may start the new chat itself: once it shows, so that + // a request that follows, such as of Run Prompt in New Chat, goes to it + if ((!actionContext || isChatViewTitleActionContext(actionContext)) && await chatViewHostService.newChat()) { return; } // Context from toolbar or lastFocusedWidget - const context = getEditingSessionContext(accessor, [actionContext, ...args.slice(1)]); - await runNewChatAction(accessor, context, executeCommandContext); + await instantiationService.invokeFunction(accessor => runNewChatAction(accessor, getEditingSessionContext(accessor, [actionContext, ...args.slice(1)]), executeCommandContext)); } export const NEW_CHAT_ICON_VARIANTS = [ diff --git a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts index a3d71979ce6426..2841dfecac1b16 100644 --- a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts +++ b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts @@ -54,6 +54,14 @@ export function getChatViewHostOpenReason(options: IChatViewHostOpenOptions | un return options?.commit ? 'commit' : options?.restore ? 'restore' : 'open'; } +/** What a new chat aims at, such as the folder and the session type of a new session that a sessions list starts. */ +export interface IChatViewNewChatTarget { + /** The folder the session of the new chat works in. */ + readonly folder?: URI; + readonly providerId?: string; + readonly sessionType?: string; +} + /** How {@link IChatViewHost.startSession} starts a session that its caller may send a first request to. */ export interface IChatViewHostSessionStartOptions { /** Whether the session is a new local chat, rather than the session of the given resource. */ @@ -104,10 +112,11 @@ export interface IChatViewHost { */ canShow?(sessionResource: URI): boolean; /** - * Starts a new chat in the host, as New Chat of the Chat view does, and returns whether it takes it. A host that does not - * implement it leaves New Chat to the chat widget it acts on. + * Starts a new chat in the host, as New Chat of the Chat view does, aimed at `target` when given, and resolves to whether + * it takes it once its new chat shows, so that a request that follows goes to it. A host that does not implement it leaves + * New Chat to the chat widget it acts on. */ - newChat?(): boolean; + newChat?(target?: IChatViewNewChatTarget): Promise; /** The chat the host shows; a host that does not implement it reports none. */ readonly shownSession?: IObservable; } @@ -121,7 +130,7 @@ export interface IChatViewHostService extends IChatViewHost { revealIfShowing(sessionResource: URI, options?: IChatViewHostOpenOptions): Promise; - newChat(): boolean; + newChat(target?: IChatViewNewChatTarget): Promise; /** Whether a registered host replaces the Chat view, which then shows only the chats the host leaves to it. */ readonly replaced: IObservable; diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts index 34ea68a48d883e..bb3567fa330aaf 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts @@ -14,7 +14,7 @@ import { IViewDescriptorService, ViewContainerLocation } from '../../../../../co import { IWorkbenchLayoutService, Parts } from '../../../../../services/layout/browser/layoutService.js'; import { IViewsService } from '../../../../../services/views/common/viewsService.js'; import { ChatViewId, IChatWidget } from '../../chat.js'; -import { getChatViewHostOpenReason, IChatViewHost, IChatViewHostOpenOptions, IChatViewHostRevealOptions, IChatViewHostService, IChatViewHostSessionStart, IChatViewHostSessionStartOptions, IChatViewHostShownSession } from '../../chatViewHost.js'; +import { getChatViewHostOpenReason, IChatViewHost, IChatViewHostOpenOptions, IChatViewHostRevealOptions, IChatViewHostService, IChatViewHostSessionStart, IChatViewHostSessionStartOptions, IChatViewHostShownSession, IChatViewNewChatTarget } from '../../chatViewHost.js'; import { ChatViewPane } from './chatViewPane.js'; /** The Chat view, as the host of its chats. */ @@ -71,8 +71,8 @@ export class ChatViewPaneHost implements IChatViewHost { return chatView?.widget; } - newChat(): boolean { - return this.viewsService.isViewVisible(ChatViewId) && !!this.viewsService.getViewWithId(ChatViewId)?.newChat(); + async newChat(target?: IChatViewNewChatTarget): Promise { + return this.viewsService.isViewVisible(ChatViewId) && !!this.viewsService.getViewWithId(ChatViewId)?.newChat(target); } async toggle(): Promise { @@ -275,9 +275,9 @@ export class ChatViewHostService extends Disposable implements IChatViewHostServ return host.startSession(sessionResource, options); } - newChat(): boolean { + async newChat(target?: IChatViewNewChatTarget): Promise { const host = this.host; - const taken = host.newChat?.() ?? false; + const taken = await host.newChat?.(target) ?? false; if (taken) { this.claim(host, true); } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index ef8b8622d84b84..7c47797d1a65a9 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -72,7 +72,7 @@ import { AgentSessionsViewerOrientation, AgentSessionsViewerPosition } from '../ import { IChatViewSessionsComposer, IChatViewSessionsList, IChatViewSessionsListControl, IChatViewSessionsListService } from './chatViewSessionsList.js'; import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; import { CHAT_WIDGET_VIEW_STATE_CACHE_LIMIT, ChatViewId, IChatWidgetService, IChatWidgetViewModelChangeEvent, IChatWidgetViewState, setModelPreservingInputTypedWhileLoading } from '../../chat.js'; -import { IChatViewHostShownSession } from '../../chatViewHost.js'; +import { IChatViewHostShownSession, IChatViewNewChatTarget } from '../../chatViewHost.js'; import { IActivityService, ProgressBadge } from '../../../../../services/activity/common/activity.js'; import { DeferredPromise, disposableTimeout, raceCancellation, RunOnceScheduler } from '../../../../../../base/common/async.js'; import { AgentSessionsFilter, AgentSessionsGrouping } from '../../agentSessions/agentSessionsFilter.js'; @@ -908,21 +908,22 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } /** - * New Chat of the Chat view: the provided composer starts over, and takes New Chat while it shows in place of the chat. - * Returns whether it took it; otherwise New Chat clears the chat, in whose place the composer then shows. + * New Chat of the Chat view: the provided composer starts over, aimed at `target` when given, and takes New Chat while it + * shows in place of the chat. Returns whether it took it; otherwise New Chat clears the chat, in whose place the composer + * then shows. */ - newChat(): boolean { + newChat(target?: IChatViewNewChatTarget): boolean { if (!this.providedComposer) { return false; } if (!this.composerShown) { - this.providedComposer.newChat(); + this.providedComposer.newChat(target); return false; } // A chat still loading or restoring, which cleared the chat and so shows the composer, no longer replaces it this.loadSessionCts.value?.cancel(); this._applyModelCts.value?.cancel(); - this.providedComposer.newChat(); + this.providedComposer.newChat(target); this.providedComposer.focus(); return true; } @@ -995,6 +996,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { this.sessionsControlContainer = append(sessionsContainer, $('.agent-sessions-control-container')); const provided = this.providedSessionsList = this.sessionsListService.provider?.create(this.sessionsControlContainer, this.instantiationService, { overrideStyles: this.getLocationBasedColors().listOverrideStyles }); if (provided) { + this._register(this.sessionsListService.setActiveList(provided)); // A list of its own header and actions, in place of those of the Chat view's list setVisibility(!provided.hasHeader, sessionsTitleContainer); setVisibility(false, newSessionButtonContainer); diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewSessionsList.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewSessionsList.ts index 1585367d18fb37..3f86377a998682 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewSessionsList.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewSessionsList.ts @@ -4,13 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; -import { IObservable } from '../../../../../../base/common/observable.js'; +import { derived, IObservable, observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { InstantiationType, registerSingleton } from '../../../../../../platform/instantiation/common/extensions.js'; import { createDecorator, IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IListStyles } from '../../../../../../base/browser/ui/list/listWidget.js'; import { IStyleOverride } from '../../../../../../platform/theme/browser/defaultStyles.js'; import { AgentSessionRefreshReason, IAgentSession } from '../../agentSessions/agentSessionsModel.js'; +import { IChatViewNewChatTarget } from '../../chatViewHost.js'; /** The sessions list that the Chat view shows in its sessions section: its own, or one that a provider gives it. */ export interface IChatViewSessionsListControl extends IDisposable { @@ -34,8 +35,11 @@ export interface IChatViewSessionsComposer extends IDisposable { readonly height: IObservable; layout(width: number, height: number): void; focus(): void; - /** Starts over with a new chat, as New Chat of the Chat view does: without the input, the attachments and the target it had. */ - newChat(): void; + /** + * Starts over with a new chat, as New Chat of the Chat view does: without the input, the attachments and the target it had, + * aimed at `target` when given, such as the folder and the session type of a new session that a sessions list starts. + */ + newChat(target?: IChatViewNewChatTarget): void; } /** A sessions list of the Chat view, with how the Chat view shows it. */ @@ -65,6 +69,12 @@ export interface IChatViewSessionsListService { readonly _serviceBrand: undefined; readonly provider: IChatViewSessionsListProvider | undefined; registerProvider(provider: IChatViewSessionsListProvider): IDisposable; + + /** Whether the Chat view shows only the sessions of the list it shows, without its chat: false while it shows no provided list. */ + readonly sessionsOnly: IObservable; + + /** Called by the Chat view with the provided list it shows, until the returned disposable is disposed. */ + setActiveList(list: IChatViewSessionsList): IDisposable; } export class ChatViewSessionsListService implements IChatViewSessionsListService { @@ -78,6 +88,18 @@ export class ChatViewSessionsListService implements IChatViewSessionsListService return this._providers.at(-1)?.provider; } + private readonly _activeList = observableValue(this, undefined); + readonly sessionsOnly: IObservable = derived(this, reader => this._activeList.read(reader)?.sessionsOnly.read(reader) ?? false); + + setActiveList(list: IChatViewSessionsList): IDisposable { + this._activeList.set(list, undefined); + return toDisposable(() => { + if (this._activeList.get() === list) { + this._activeList.set(undefined, undefined); + } + }); + } + registerProvider(provider: IChatViewSessionsListProvider): IDisposable { const registration = { provider }; this._providers.push(registration); diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatNewActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatNewActions.test.ts index 2b3713ee1c5e22..5d00ded7079f22 100644 --- a/src/vs/workbench/contrib/chat/test/browser/actions/chatNewActions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/actions/chatNewActions.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; import { KeyCode, KeyMod } from '../../../../../../base/common/keyCodes.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { ContextKeyValue, IContext } from '../../../../../../platform/contextkey/common/contextkey.js'; @@ -11,7 +12,7 @@ import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { ChatAgentLocation } from '../../../common/constants.js'; import { NEW_CHAT_ICON_VARIANTS, NewChatAction, newChatIconVariantAction } from '../../../browser/actions/chatNewActions.js'; import { IChatViewHostService } from '../../../browser/chatViewHost.js'; -import { ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; import { upcastPartial } from '../../../../../../base/test/common/mock.js'; function context(values: Record): IContext { @@ -55,8 +56,8 @@ suite('Chat new actions', () => { let started = 0; const accessor = upcastPartial({ get: ((id: unknown) => { - assert.strictEqual(id, IChatViewHostService); - return { newChat: () => { started++; return true; } } as T; + assert.ok(id === IChatViewHostService || id === IInstantiationService); + return { newChat: async () => { started++; return true; } } as T; }) as ServicesAccessor['get'], }); for (const action of [new NewChatAction(), ...NEW_CHAT_ICON_VARIANTS.map(variant => new (newChatIconVariantAction(variant))())]) { @@ -65,4 +66,20 @@ suite('Chat new actions', () => { assert.strictEqual(started, 1 + NEW_CHAT_ICON_VARIANTS.length); }); + + test('New Chat completes only once the host that replaces the Chat view shows its new chat, so that a prompt that follows goes to it', async () => { + const shows = new DeferredPromise(); + const steps: string[] = []; + const accessor = upcastPartial({ + get: (() => ({ newChat: () => shows.p }) as T) as ServicesAccessor['get'], + }); + + const run = new NewChatAction().run(accessor).then(() => steps.push('New Chat completes')); + await timeout(0); + steps.push('host shows its new chat'); + shows.complete(true); + await run; + + assert.deepStrictEqual(steps, ['host shows its new chat', 'New Chat completes']); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts index e138435f7e120b..4e4b6139e44b6d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts @@ -162,16 +162,16 @@ suite('ChatViewHostService', () => { }); }); - test('New Chat is left to the chat widget, unless the host that replaces the Chat view starts it', () => { + test('New Chat is left to the chat widget, unless the host that replaces the Chat view starts it', async () => { const { service } = createService(); let started = 0; const withoutNewChat = service.registerHost(upcastPartial({})); - const results = [service.newChat()]; - const withNewChat = service.registerHost(upcastPartial({ newChat: () => { started++; return true; } })); - results.push(service.newChat()); + const results = [await service.newChat()]; + const withNewChat = service.registerHost(upcastPartial({ newChat: async () => { started++; return true; } })); + results.push(await service.newChat()); withNewChat.dispose(); withoutNewChat.dispose(); - results.push(service.newChat()); + results.push(await service.newChat()); assert.deepStrictEqual({ results, started }, { results: [false, true, false], started: 1 }); }); @@ -277,7 +277,7 @@ suite('ChatViewHostService', () => { canShow: resource => resource.scheme !== localChat.scheme, openSession: async () => undefined, reveal: async () => undefined, - newChat: () => takesNewChat, + newChat: async () => takesNewChat, }); const steps: string[] = []; await service.openSession(localChat); @@ -287,10 +287,10 @@ suite('ChatViewHostService', () => { steps.push(shown()); await service.openSession(localChat); // New Chat that the host does not take changes nothing - service.newChat(); + await service.newChat(); steps.push(shown()); takesNewChat = true; - service.newChat(); + await service.newChat(); steps.push(shown()); // The new chat starts a session hostShown.set({ resource: agentHostChat.with({ path: '/started' }), reason: 'open' }, undefined); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewSessionsList.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewSessionsList.test.ts new file mode 100644 index 00000000000000..5ce871fdd9a5d6 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewSessionsList.test.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { observableValue } from '../../../../../../../base/common/observable.js'; +import { upcastPartial } from '../../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { ChatViewSessionsListService, IChatViewSessionsList } from '../../../../browser/widgetHosts/viewPane/chatViewSessionsList.js'; + +suite('ChatViewSessionsListService', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('reports whether the Chat view shows only the sessions of the provided list it shows, and not once it no longer shows that list', () => { + const service = new ChatViewSessionsListService(); + const sessionsOnly = observableValue('sessionsOnly', true); + const steps: boolean[] = [service.sessionsOnly.get()]; + + const active = service.setActiveList(upcastPartial({ sessionsOnly })); + steps.push(service.sessionsOnly.get()); + sessionsOnly.set(false, undefined); + steps.push(service.sessionsOnly.get()); + sessionsOnly.set(true, undefined); + active.dispose(); + steps.push(service.sessionsOnly.get()); + + assert.deepStrictEqual(steps, [false, true, false, false]); + }); +}); From 9e677150d48015a658f193b7e086c5a7e5a6d638 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 06:55:02 +0100 Subject: [PATCH 065/165] Chat part actions test: check that disposing restores the read-state command registered before Another module may register the sessions list's read-state commands too, as the sessions list does. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/browser/agentsView/agentsChatPartActions.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPartActions.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPartActions.test.ts index 36cacca2a5b926..44d176b2f126a2 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPartActions.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPartActions.test.ts @@ -140,6 +140,7 @@ suite('Agents chat part actions', () => { const read = new MarkAgentSessionReadAction(); const unread = new MarkAgentSessionUnreadAction(); + const before = CommandsRegistry.getCommand(SESSIONS_LIST_MARK_READ_COMMAND_ID); const registration = registerAgentsChatPartActions(); // A chat tab of a side chat and of a session's main chat run the agent sessions' commands for (const [action, context] of [[read, { session, chat: side }], [unread, { session, chat: main }]] as const) { @@ -147,9 +148,9 @@ suite('Agents chat part actions', () => { } registration.dispose(); - assert.deepStrictEqual({ marked, registeredAfterDispose: !!CommandsRegistry.getCommand(SESSIONS_LIST_MARK_READ_COMMAND_ID) }, { + assert.deepStrictEqual({ marked, restoredAfterDispose: CommandsRegistry.getCommand(SESSIONS_LIST_MARK_READ_COMMAND_ID) === before }, { marked: ['read chat side', 'unread s1'], - registeredAfterDispose: false, + restoredAfterDispose: true, }); }); }); From 1ee85e81d29f082dad1e04fc4c3d59411efd2ecc Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:06:26 +0100 Subject: [PATCH 066/165] Chat part: scoped New Chat of the Chat view, restore intent before activation, anchor and Restore fixes - The editor window's default presenter starts a new chat of the Chat view's own (IChatViewHost.newChat with ownChat), in place of the chat it shows and a load under way, honouring preserveFocus, rather than New Chat of the chat widget that has focus. A registered host that leaves New Chat to the Chat view falls through. - The chat part records why an open shows a tab before the tab can activate, so a tab recalled as restored never reports as opened in between. - The chat part follows its anchor view also as the view is deregistered. - Restore of a maximized secondary side bar stays where its view container hides Maximize. - Trust is the caller's to ask for on the editor window's open methods, as in the Agents window. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/workbench/browser/layout.ts | 23 ++++--- .../parts/auxiliarybar/auxiliaryBarActions.ts | 3 +- .../contrib/chat/browser/chatViewHost.ts | 15 ++++- .../viewPane/chatViewHostService.ts | 21 ++++-- .../widgetHosts/viewPane/chatViewPane.ts | 26 +++++--- .../viewPane/chatViewHostService.test.ts | 13 +++- .../widgetHosts/viewPane/chatViewPane.test.ts | 44 +++++++++++-- .../browser/agentsView/agentsChatPart.ts | 29 +++++++-- .../browser/agentsView/agentsChatViewHost.ts | 6 +- .../agentsView/editorWindowSessionsService.ts | 6 +- .../editorWindowSessionsPresenter.ts | 19 ++---- .../browser/agentsView/agentsChatPart.test.ts | 31 ++++++++- .../editorWindowSessionsRuntime.test.ts | 64 ++++++++++++++----- src/vs/workbench/test/browser/layout.test.ts | 23 ++++++- .../browser/parts/auxiliaryBarPart.test.ts | 25 ++++++++ 15 files changed, 275 insertions(+), 73 deletions(-) diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index 3424609d74b77a..57fedb2e5be673 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -1806,14 +1806,8 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi })); this._register(this.storageService.onWillSaveState(() => this.saveChatPartLayout())); - // The chat part follows its anchor view as the view, its container or its location changes - this._register(Event.any( - this.viewDescriptorService.onDidChangeContainerLocation, - this.viewDescriptorService.onDidChangeContainer, - this.viewDescriptorService.onDidChangeLocation, - this.viewDescriptorService.onDidChangeViewContainers, - Registry.as(ViewExtensions.ViewsRegistry).onViewsRegistered - )(() => this.updateChatPartPosition())); + // The chat part follows its anchor view as the view, its container or its location changes, and as it comes or goes + this._register(this.onDidChangeChatPartAnchorLocation()(() => this.updateChatPartPosition())); } // The floating cards abut the top window edge only while neither of the two grid rows @@ -2546,6 +2540,19 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi } } + /** Fires as the location of the chat part's anchor view may change: as views or their containers move, register or deregister. */ + private onDidChangeChatPartAnchorLocation(): Event { + const viewsRegistry = Registry.as(ViewExtensions.ViewsRegistry); + return Event.any( + this.viewDescriptorService.onDidChangeContainerLocation, + this.viewDescriptorService.onDidChangeContainer, + this.viewDescriptorService.onDidChangeLocation, + this.viewDescriptorService.onDidChangeViewContainers, + viewsRegistry.onViewsRegistered, + viewsRegistry.onViewsDeregistered + ); + } + /** Moves the chat part to the side of the editor it belongs on, if it is not there already. */ private updateChatPartPosition(): void { if (!this.chatPartView || !this.workbenchGrid || this.chatPartPosition === this.computeChatPartPosition()) { diff --git a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts index afe55c3142ac13..71a1d3eff2799e 100644 --- a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts +++ b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts @@ -277,7 +277,8 @@ class ToggleMaximizedAuxiliaryBar extends Action2 { id: MenuId.AuxiliaryBarTitle, group: 'navigation', order: 1, - when: AuxiliaryBarMaximizeHiddenContext.negate(), + // Restore stays while it is maximized, also where its view container does not offer to maximize it + when: ContextKeyExpr.or(AuxiliaryBarMaximizeHiddenContext.negate(), AuxiliaryBarMaximizedContext), } }); } diff --git a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts index 2841dfecac1b16..803b409e773f1e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts +++ b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts @@ -62,6 +62,16 @@ export interface IChatViewNewChatTarget { readonly sessionType?: string; } +/** How {@link IChatViewHost.newChat} starts a new chat. */ +export interface IChatViewHostNewChatOptions { + readonly preserveFocus?: boolean; + /** + * Starts the new chat in the host's own view, also where New Chat would leave it to the chat widget it acts on, such as the + * Chat view that shows a chat: a new session that a sessions list starts, rather than New Chat of a chat widget. + */ + readonly ownChat?: boolean; +} + /** How {@link IChatViewHost.startSession} starts a session that its caller may send a first request to. */ export interface IChatViewHostSessionStartOptions { /** Whether the session is a new local chat, rather than the session of the given resource. */ @@ -116,7 +126,7 @@ export interface IChatViewHost { * it takes it once its new chat shows, so that a request that follows goes to it. A host that does not implement it leaves * New Chat to the chat widget it acts on. */ - newChat?(target?: IChatViewNewChatTarget): Promise; + newChat?(target?: IChatViewNewChatTarget, options?: IChatViewHostNewChatOptions): Promise; /** The chat the host shows; a host that does not implement it reports none. */ readonly shownSession?: IObservable; } @@ -130,7 +140,8 @@ export interface IChatViewHostService extends IChatViewHost { revealIfShowing(sessionResource: URI, options?: IChatViewHostOpenOptions): Promise; - newChat(target?: IChatViewNewChatTarget): Promise; + /** A new chat of its own (`ownChat`) that the host in use leaves to the Chat view starts in the Chat view. */ + newChat(target?: IChatViewNewChatTarget, options?: IChatViewHostNewChatOptions): Promise; /** Whether a registered host replaces the Chat view, which then shows only the chats the host leaves to it. */ readonly replaced: IObservable; diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts index d4c7ed5dceb60b..69f216f62dde95 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts @@ -14,7 +14,7 @@ import { IViewDescriptorService, ViewContainerLocation } from '../../../../../co import { IWorkbenchLayoutService, Parts } from '../../../../../services/layout/browser/layoutService.js'; import { IViewsService } from '../../../../../services/views/common/viewsService.js'; import { ChatViewId, IChatWidget } from '../../chat.js'; -import { getChatViewHostOpenReason, IChatViewHost, IChatViewHostOpenOptions, IChatViewHostRevealOptions, IChatViewHostService, IChatViewHostSessionStart, IChatViewHostSessionStartOptions, IChatViewHostShownSession, IChatViewNewChatTarget } from '../../chatViewHost.js'; +import { getChatViewHostOpenReason, IChatViewHost, IChatViewHostOpenOptions, IChatViewHostRevealOptions, IChatViewHostService, IChatViewHostSessionStart, IChatViewHostSessionStartOptions, IChatViewHostNewChatOptions, IChatViewHostShownSession, IChatViewNewChatTarget } from '../../chatViewHost.js'; import { ChatViewPane } from './chatViewPane.js'; /** The Chat view, as the host of its chats. */ @@ -71,8 +71,13 @@ export class ChatViewPaneHost implements IChatViewHost { return chatView?.widget; } - async newChat(target?: IChatViewNewChatTarget): Promise { - return this.viewsService.isViewVisible(ChatViewId) && !!this.viewsService.getViewWithId(ChatViewId)?.newChat(target); + async newChat(target?: IChatViewNewChatTarget, options?: IChatViewHostNewChatOptions): Promise { + if (options?.ownChat) { + const view = await this.viewsService.openView(ChatViewId, !options.preserveFocus); + return !!view && await view.newChat(target, options); + } + const view = this.viewsService.isViewVisible(ChatViewId) ? this.viewsService.getViewWithId(ChatViewId) : undefined; + return !!view && await view.newChat(target, options); } async toggle(): Promise { @@ -271,9 +276,13 @@ export class ChatViewHostService extends Disposable implements IChatViewHostServ return host.startSession(sessionResource, options); } - async newChat(target?: IChatViewNewChatTarget): Promise { - const host = this.host; - const taken = await host.newChat?.(target) ?? false; + async newChat(target?: IChatViewNewChatTarget, options?: IChatViewHostNewChatOptions): Promise { + let host = this.host; + let taken = await host.newChat?.(target, options) ?? false; + if (!taken && options?.ownChat && host !== this.chatViewHost) { + host = this.chatViewHost; + taken = await host.newChat?.(target, options) ?? false; + } if (taken) { this.claim(host, true); } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 252cc583b961bd..d0a8ac72b3637e 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -72,7 +72,7 @@ import { AgentSessionsViewerOrientation, AgentSessionsViewerPosition } from '../ import { IChatViewSessionsComposer, IChatViewSessionsList, IChatViewSessionsListControl, IChatViewSessionsListService } from './chatViewSessionsList.js'; import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; import { CHAT_WIDGET_VIEW_STATE_CACHE_LIMIT, ChatViewId, IChatWidgetService, IChatWidgetViewModelChangeEvent, IChatWidgetViewState, setModelPreservingInputTypedWhileLoading } from '../../chat.js'; -import { IChatViewHostShownSession, IChatViewNewChatTarget } from '../../chatViewHost.js'; +import { IChatViewHostNewChatOptions, IChatViewHostShownSession, IChatViewNewChatTarget } from '../../chatViewHost.js'; import { IActivityService, ProgressBadge } from '../../../../../services/activity/common/activity.js'; import { DeferredPromise, disposableTimeout, raceCancellation, RunOnceScheduler } from '../../../../../../base/common/async.js'; import { AgentSessionsFilter, AgentSessionsGrouping } from '../../agentSessions/agentSessionsFilter.js'; @@ -904,20 +904,30 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { /** * New Chat of the Chat view: the provided composer starts over, aimed at `target` when given, and takes New Chat while it * shows in place of the chat. Returns whether it took it; otherwise New Chat clears the chat, in whose place the composer - * then shows. + * then shows. With `ownChat`, it always takes it: it starts a new chat in place of the one it shows, also one still loading. */ - newChat(target?: IChatViewNewChatTarget): boolean { - if (!this.providedComposer) { + async newChat(target?: IChatViewNewChatTarget, options?: IChatViewHostNewChatOptions): Promise { + if (options?.ownChat) { + if (!this.composerShown) { + // Its own new chat, rather than the chat widget that New Chat acts on, such as one that has focus elsewhere + await this.clear(); + } + } else if (!this.providedComposer) { return false; - } - if (!this.composerShown) { + } else if (!this.composerShown) { this.providedComposer.newChat(target); return false; } // A chat still loading, which cleared the chat and so shows the composer, no longer replaces it this.loadSessionCts.value?.cancel(); - this.providedComposer.newChat(target); - this.providedComposer.focus(); + this.providedComposer?.newChat(target); + if (!options?.preserveFocus) { + if (this.providedComposer) { + this.providedComposer.focus(); + } else { + this._widget?.focusInput(); + } + } return true; } diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts index 9658bbde02fa69..25a7bced76401f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts @@ -18,7 +18,7 @@ import { IWorkbenchLayoutService, Parts } from '../../../../../../services/layou import { IViewsService } from '../../../../../../services/views/common/viewsService.js'; import { ChatViewId } from '../../../../browser/chat.js'; import { ChatWidget } from '../../../../browser/widget/chatWidget.js'; -import { IChatViewHost, IChatViewHostShownSession } from '../../../../browser/chatViewHost.js'; +import { IChatViewHost, IChatViewHostNewChatOptions, IChatViewHostShownSession, IChatViewNewChatTarget } from '../../../../browser/chatViewHost.js'; import { ChatViewHostService } from '../../../../browser/widgetHosts/viewPane/chatViewHostService.js'; import { ChatViewPane } from '../../../../browser/widgetHosts/viewPane/chatViewPane.js'; @@ -176,6 +176,17 @@ suite('ChatViewHostService', () => { assert.deepStrictEqual({ results, started }, { results: [false, true, false], started: 1 }); }); + test('a new chat of its own that the host in use leaves to the Chat view starts in the Chat view, which it shows', async () => { + const { service, view, calls } = createService(); + Object.assign(view, { newChat: async (target: IChatViewNewChatTarget | undefined, options: IChatViewHostNewChatOptions | undefined) => { calls.push(`view.newChat ${target?.folder?.path} ownChat=${options?.ownChat}`); return true; } }); + const registration = service.registerHost(upcastPartial({ newChat: async () => { calls.push('host.newChat'); return false; } })); + + const taken = await service.newChat({ folder: URI.file('/repo') }, { ownChat: true, preserveFocus: true }); + registration.dispose(); + + assert.deepStrictEqual({ taken, calls }, { taken: true, calls: ['host.newChat', `openView ${ChatViewId} focus=false`, 'view.newChat /repo ownChat=true'] }); + }); + test('reveal waits for the Chat view\'s widget, which its body creates as it first renders, only when asked to', async () => { const { service, view, created, accesses } = createService({ widgetCreatedLater: true }); const unchanged = await service.reveal(false); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index 1d93852cb9cfeb..6e7a0285f3c31e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -22,7 +22,7 @@ import { TestContextService, TestStorageService } from '../../../../../../test/c import { ChatViewPane } from '../../../../browser/widgetHosts/viewPane/chatViewPane.js'; import { ChatViewSessionsListService, IChatViewSessionsListProvider } from '../../../../browser/widgetHosts/viewPane/chatViewSessionsList.js'; import { IChatWidgetViewModelChangeEvent } from '../../../../browser/chat.js'; -import { IChatViewHostShownSession } from '../../../../browser/chatViewHost.js'; +import { IChatViewHostNewChatOptions, IChatViewHostShownSession, IChatViewNewChatTarget } from '../../../../browser/chatViewHost.js'; import { ChatWidget } from '../../../../browser/widget/chatWidget.js'; import { mainWindow } from '../../../../../../../base/browser/window.js'; import { ChatOpenDiagnosticsService } from '../../../../common/chatOpenDiagnosticsService.js'; @@ -128,15 +128,15 @@ suite('ChatViewPane sessions refresh', () => { assert.deepStrictEqual({ states, withoutComposer: withoutComposer.composerShown }, { states: [true, false, true], withoutComposer: false }); }); - test('New Chat starts the provided composer over, and is the composer\'s while it shows in place of the chat', () => { + test('New Chat starts the provided composer over, and is the composer\'s while it shows in place of the chat', async () => { const calls: string[] = []; const composer = { newChat: () => calls.push('newChat'), focus: () => calls.push('focus') }; - const newChat = Reflect.get(ChatViewPane.prototype, 'newChat') as (this: { providedComposer: typeof composer | undefined; composerShown: boolean; loadSessionCts: { value: undefined } }) => boolean; + const newChat = Reflect.get(ChatViewPane.prototype, 'newChat') as (this: { providedComposer: typeof composer | undefined; composerShown: boolean; loadSessionCts: { value: undefined } }) => Promise; const loadSessionCts = { value: undefined }; const results = { - shown: newChat.call({ providedComposer: composer, composerShown: true, loadSessionCts }), - hidden: newChat.call({ providedComposer: composer, composerShown: false, loadSessionCts }), - withoutComposer: newChat.call({ providedComposer: undefined, composerShown: false, loadSessionCts }), + shown: await newChat.call({ providedComposer: composer, composerShown: true, loadSessionCts }), + hidden: await newChat.call({ providedComposer: composer, composerShown: false, loadSessionCts }), + withoutComposer: await newChat.call({ providedComposer: undefined, composerShown: false, loadSessionCts }), }; assert.deepStrictEqual({ results, calls }, { results: { shown: true, hidden: false, withoutComposer: false }, calls: ['newChat', 'focus', 'newChat'] }); }); @@ -506,7 +506,7 @@ suite('ChatViewPane session transitions', () => { await timeout(150); // The cleared chat is a new chat: the composer shows in its place Object.assign(rig.pane, { composerShown: true }); - const taken = (Reflect.get(ChatViewPane.prototype, 'newChat') as (this: object) => boolean).call(rig.pane); + const taken = await (Reflect.get(ChatViewPane.prototype, 'newChat') as (this: object) => Promise).call(rig.pane); await acquiring.complete(rig.nextRef); await loading; assert.deepStrictEqual({ taken, composerCalls, bindings: rig.bindings, disposed: rig.disposed }, { @@ -517,6 +517,36 @@ suite('ChatViewPane session transitions', () => { }); })); + test('a new chat of its own, as a sessions list starts one, replaces the chat it shows and its slow load, aimed at the target, without focus', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const rig = createPane(); + const acquiring = new DeferredPromise(); + const freshResource = URI.from({ scheme: SessionType.AgentHostCopilot, path: '/untitled-fresh' }); + const freshRef = Object.assign(toDisposable(() => { }), { object: upcastPartial({ sessionResource: freshResource }) }); + const calls: string[] = []; + Object.assign(rig.pane, { + chatService: { acquireOrLoadSession: () => acquiring.p }, + acquireDefaultNewSession: async () => ({ modelRef: freshRef }), + updateViewState: () => { }, + providedComposer: { newChat: (target?: IChatViewNewChatTarget) => calls.push(`composer newChat ${target?.folder?.path}`), focus: () => calls.push('composer focus') }, + composerShown: false, + }); + // A load still under way, while the chat it replaces still shows + const loading = rig.pane.loadSession(nextResource); + await timeout(150); + + const taken = await (Reflect.get(ChatViewPane.prototype, 'newChat') as (this: object, target: IChatViewNewChatTarget, options: IChatViewHostNewChatOptions) => Promise) + .call(rig.pane, { folder: URI.file('/repo') }, { ownChat: true, preserveFocus: true }); + await acquiring.complete(rig.nextRef); + await loading; + + assert.deepStrictEqual({ taken, calls, bindings: rig.bindings, shown: rig.widget.viewModel?.sessionResource.path }, { + taken: true, + calls: ['composer newChat /repo'], + bindings: [freshResource], + shown: '/untitled-fresh', + }); + })); + for (const untitled of [true, false]) { test(`${untitled ? 'new chats stay bound' : 'saved chats still clear'} while a slow load is pending`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { const rig = createPane(); diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts index e4e8ade4564e92..3117324c24d915 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts @@ -212,6 +212,8 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart private readonly _explicitOpens = observableValue(this, 0); /** The tabs that {@link restore} shows, while it does: a chat the user opens meanwhile is opened, not restored. */ private readonly _restoringTabs = new Set(); + /** Why each tab that an open shows is to show, until it activates as it opens: the latest open of a tab supersedes earlier ones. */ + private readonly _pendingOpens = new Map(); /** * The tabs of new-session composers, until the user opens or picks one once it shows the session its first send created: * that session shows as committed, not opened. @@ -492,12 +494,24 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart ?? (options?.sideBySide && !part.activeGroup.isEmpty ? part.addGroup(part.activeGroup, GroupDirection.RIGHT) : part.activeGroup); // An explicit open of a tab that the chat part still restores is the user's: it is opened, not restored this._restoringTabs.delete(chat.input); - await this._editorService.openEditor(chat.input, { pinned: true, preserveFocus: options?.preserveFocus }, group); + // Why it shows, from before the tab can activate as it opens, which reports it as it does + const request = { reason: getChatViewHostOpenReason(options) }; + this._pendingOpens.set(chat.input, request); + let unconsumed = false; + try { + await this._editorService.openEditor(chat.input, { pinned: true, preserveFocus: options?.preserveFocus }, group); + } finally { + // Not taken as the tab activated: it was the active tab already, unless a later open of it supersedes this one + unconsumed = this._pendingOpens.get(chat.input) === request; + if (unconsumed) { + this._pendingOpens.delete(chat.input); + } + } this.composers.didOpen([chat.chat.get()]); - // Opened, also a tab it restored and shows already, such as one the user picks in a sessions list, unless shown as restored or committed - if (this._activeInput.get() === chat.input) { + // Opened again while it is the active tab, such as a tab the user picks in a sessions list: reported again + if (unconsumed && this._activeInput.get() === chat.input) { transaction(tx => { - this._activeReason.set(getChatViewHostOpenReason(options), tx); + this._activeReason.set(request.reason, tx); this._settleComposerTab(chat.input, tx); this._explicitOpens.set(this._explicitOpens.get() + 1, tx); }); @@ -600,8 +614,13 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart const updateActive = () => { const active = part.activeGroup.activeEditor ?? undefined; if (active !== this._activeInput.get()) { + // As an open asks it to show, or else restored while the chat part restores it, and opened otherwise + const pending = active && this._pendingOpens.get(active); + if (active && pending) { + this._pendingOpens.delete(active); + } transaction(tx => { - this._activeReason.set(active && this._restoringTabs.has(active) ? 'restore' : 'open', tx); + this._activeReason.set(pending ? pending.reason : active && this._restoringTabs.has(active) ? 'restore' : 'open', tx); this._activeInput.set(active, tx); if (active) { this._settleComposerTab(active, tx); diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts index 710be9c50ea65e..caec79ec2a536f 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts @@ -16,7 +16,7 @@ import { INotificationService } from '../../../../../platform/notification/commo import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IWorkbenchLayoutService, Parts } from '../../../../services/layout/browser/layoutService.js'; import { IChatWidget, IChatWidgetService } from '../../../chat/browser/chat.js'; -import { IChatViewHost, IChatViewHostOpenOptions, IChatViewHostSessionStart, IChatViewHostSessionStartOptions, IChatViewHostShownSession, IChatViewNewChatTarget } from '../../../chat/browser/chatViewHost.js'; +import { IChatViewHost, IChatViewHostOpenOptions, IChatViewHostSessionStart, IChatViewHostSessionStartOptions, IChatViewHostNewChatOptions, IChatViewHostShownSession, IChatViewNewChatTarget } from '../../../chat/browser/chatViewHost.js'; import { IChatService } from '../../../chat/common/chatService/chatService.js'; import { isAgentHostSessionResource, localChatSessionType } from '../../../chat/common/chatSessionsService.js'; import { ChatAgentLocation } from '../../../chat/common/constants.js'; @@ -92,12 +92,12 @@ export class AgentsChatViewHost implements IChatViewHost { * New Chat, such as + in the title of the Chat view, opens a new-session composer in the chat part, unless the Chat view * keeps it. Resolves once the composer shows, so that a request that follows goes to it, not to the chat shown before. */ - async newChat(target?: IChatViewNewChatTarget): Promise { + async newChat(target?: IChatViewNewChatTarget, options?: IChatViewHostNewChatOptions): Promise { if (!this._ownsNewChat.get() || !this._available) { return false; } const aimed = target ? { folderUri: target.folder, providerId: target.providerId, sessionTypeId: target.sessionType } : this._composerTarget(); - await this._chatPart.openComposer(aimed, { newComposer: true }); + await this._chatPart.openComposer(aimed, { newComposer: true, preserveFocus: options?.preserveFocus }); return true; } diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsService.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsService.ts index 745f1a902d57e7..f775e8e728148b 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsService.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsService.ts @@ -172,7 +172,11 @@ export class EditorWindowSessionsService extends Disposable implements ISessions await this._presenter.openChat(session, chatResource, { preserveFocus: options?.preserveFocus, sideBySide: true }); } - /** Whether the user trusts every folder the session works in, as the Agents window asks before it opens a session. */ + /** + * Whether the user trusts every folder the session works in, as the Agents window asks before it opens a session. Asking + * is the caller's, as in the Agents window: {@link openSession}, {@link openSessionToSide}, {@link openChat} and + * {@link openChatToSide} open without asking, and the sessions list asks with this before it opens a session. + */ async canOpenSession(session: ISession): Promise { const workspace = session.workspace.get(); return requestSessionWorkspaceTrust(workspace, () => workspace && this._sessionsManagementService.getRemoteHostLabel(workspace, session.providerId), this._workspaceTrustManagementService, this._workspaceTrustRequestService); diff --git a/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsPresenter.ts b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsPresenter.ts index 4ef82ae286570c..973f30a0d0d7dd 100644 --- a/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsPresenter.ts +++ b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsPresenter.ts @@ -6,13 +6,11 @@ import { Disposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { constObservable, derived, IObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; -import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { EditorResourceAccessor, SideBySideEditor } from '../../../../common/editor.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IChat, ISession } from '../../../../services/sessions/common/session.js'; -import { ACTION_ID_NEW_CHAT } from '../../../chat/browser/actions/chatActions.js'; import { IChatViewHostService } from '../../../chat/browser/chatViewHost.js'; import { INewSessionTarget } from '../parts/chatView.js'; @@ -94,7 +92,6 @@ class ChatViewSessionsPresenter implements IEditorWindowSessionsPresenter { constructor( private readonly _chatViewHostService: IChatViewHostService, - private readonly _commandService: ICommandService, ) { } async openChat(session: ISession, chatResource: URI | undefined, options?: IEditorWindowPresenterOpenOptions): Promise { @@ -102,16 +99,13 @@ class ChatViewSessionsPresenter implements IEditorWindowSessionsPresenter { } /** - * A new chat in the Chat view, as its New Chat starts one: the next request does not go to the chat it showed. The - * composer that the Chat view then shows, such as the one of its sessions list, is aimed at `target`. + * A new chat in the Chat view, in place of the chat it shows, also one still loading: the next request does not go to that + * chat. Not New Chat of the chat widget that has focus, which may be another. The composer that the Chat view then shows, + * such as the one of its sessions list, is aimed at `target`. */ async openComposer(target: INewSessionTarget | undefined, options?: IEditorWindowPresenterOpenOptions): Promise { - await this._chatViewHostService.reveal(options?.preserveFocus); - await this._commandService.executeCommand(ACTION_ID_NEW_CHAT); - // Aimed only after New Chat, which starts its composer over without a target - if (target) { - await this._chatViewHostService.newChat({ folder: target.folderUri, providerId: target.providerId, sessionType: target.sessionTypeId }); - } + const aimed = target && { folder: target.folderUri, providerId: target.providerId, sessionType: target.sessionTypeId }; + await this._chatViewHostService.newChat(aimed, { ownChat: true, preserveFocus: options?.preserveFocus }); } async closeChat(): Promise { } @@ -136,10 +130,9 @@ export class EditorWindowSessionsPresenterService extends Disposable implements constructor( @IChatViewHostService chatViewHostService: IChatViewHostService, - @ICommandService commandService: ICommandService, ) { super(); - const chatView = new ChatViewSessionsPresenter(chatViewHostService, commandService); + const chatView = new ChatViewSessionsPresenter(chatViewHostService); this.presenter = derived(this, reader => this._registered.read(reader).at(-1)?.presenter ?? chatView); } diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts index 9b4c697de33db1..03d8fcacb1f648 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts @@ -42,7 +42,7 @@ import { isEqual } from '../../../../../../base/common/resources.js'; import { buildTestSession } from '../../common/testSessionBuilder.js'; import { ISessionsListModelService } from '../../../browser/sessionsListModelService.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { timeout } from '../../../../../../base/common/async.js'; +import { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; /** An editor group that keeps its editors in memory, as the chat part's groups do, without rendering them. */ class FakeGroup { @@ -149,7 +149,7 @@ suite('AgentsChatPartService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - function createService(options?: { readonly stored?: string; readonly visible?: boolean; readonly chatEnabled?: boolean; readonly sessions?: () => readonly ISession[]; readonly newSession?: IObservable }) { + function createService(options?: { readonly stored?: string; readonly visible?: boolean; readonly chatEnabled?: boolean; readonly sessions?: () => readonly ISession[]; readonly newSession?: IObservable; readonly holdOpen?: () => Promise }) { const store = disposables.add(new DisposableStore()); const instantiationService = workbenchInstantiationService(undefined, store); const contextKeyService = store.add(new ChangingContextKeyService()); @@ -187,6 +187,8 @@ suite('AgentsChatPartService', () => { if (!editorOptions?.inactive) { part.activate(group); } + // An open may complete only after its editor activates, as the editor part resolves it + await options?.holdOpen?.(); return undefined; }) as IEditorService['openEditor'], openEditors: async (editors: EditorInputWithOptions[], group?: PreferredGroup) => { @@ -369,6 +371,31 @@ suite('AgentsChatPartService', () => { assert.deepStrictEqual({ restored, committed, opened: await shown() }, { restored: 'main restore', committed: 'main commit', opened: 'main open' }); }); + test('a tab recalled as restored that activates before its open completes shows as restored, never as opened in between', async () => { + const recalled = buildTestSession({ id: 'recalled', title: 'Recalled' }); + const other = buildTestSession({ id: 'other', title: 'Other' }); + const recalledChat = recalled.mainChat.chat.resource; + const otherChat = other.mainChat.chat.resource; + let held: DeferredPromise | undefined = undefined; + const { service } = createService({ sessions: () => [recalled.session, other.session], holdOpen: () => held?.p ?? Promise.resolve() }); + await service.openChat(recalled.session, recalledChat); + await service.openChat(other.session, otherChat); + await timeout(0); + const reports: string[] = []; + const listener = autorun(reader => { const value = service.shownSession.read(reader); if (value) { reports.push(`${isEqual(value.resource, recalledChat) ? 'recalled' : 'other'} ${value.reason}`); } }); + + held = new DeferredPromise(); + const recall = service.openChat(recalled.session, recalledChat, { restore: true }); + await timeout(0); + const whileHeld = [...reports]; + held.complete(); + await recall; + await timeout(0); + listener.dispose(); + + assert.deepStrictEqual({ whileHeld, reports }, { whileHeld: ['other open', 'recalled restore'], reports: ['other open', 'recalled restore'] }); + }); + test('its composers that aim at the folder their host aimed them at follow it to another folder, also the active one a new session reuses', async () => { const { service, composerFolders } = createService(); diff --git a/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts b/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts index c0a0558fdd5aad..55f0fc2bf8a415 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts @@ -15,8 +15,6 @@ import { IAgentsViewProvidersService } from '../../../browser/agentsView/agentsV import { EditorWindowSessionsRuntime } from '../../../browser/editorWindow/editorWindowSessionsRuntime.js'; import { EditorWindowSessionsPresenterService, IEditorWindowSessionsPresenter, IEditorWindowSessionsPresenterService } from '../../../browser/editorWindow/editorWindowSessionsPresenter.js'; import { IChatViewHostService } from '../../../../chat/browser/chatViewHost.js'; -import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; -import { ACTION_ID_NEW_CHAT } from '../../../../chat/browser/actions/chatActions.js'; import { URI } from '../../../../../../base/common/uri.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; @@ -24,6 +22,10 @@ import { IEditorService } from '../../../../../services/editor/common/editorServ import { ISessionHost } from '../../../../../services/sessions/browser/sessionHost.js'; import { EditorWindowSessionsService } from '../../../browser/agentsView/editorWindowSessionsService.js'; import { ISessionsManagementService } from '../../../common/sessionsManagement.js'; +import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; +import { ISession, ISessionWorkspace } from '../../../../../services/sessions/common/session.js'; +import { constObservable } from '../../../../../../base/common/observable.js'; +import { buildTestSession } from '../../common/testSessionBuilder.js'; suite('EditorWindowSessionsRuntime', () => { @@ -119,28 +121,25 @@ suite('EditorWindowSessionsPresenterService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('without a presenter, a new session starts a new chat in the Chat view, rather than the next request going to the chat it showed, aimed at its target', async () => { + test('without a presenter, a new session starts a new chat of the Chat view\'s own, not of the chat widget that has focus, aimed at its target', async () => { const calls: string[] = []; const service = disposables.add(new EditorWindowSessionsPresenterService( upcastPartial({ - reveal: async preserveFocus => { calls.push(`reveal preserveFocus=${preserveFocus}`); return undefined; }, - newChat: async target => { calls.push(`newChat ${target?.folder?.path} provider=${target?.providerId} type=${target?.sessionType}`); return true; }, + newChat: async (target, options) => { calls.push(`newChat ${target?.folder?.path} provider=${target?.providerId} type=${target?.sessionType} ownChat=${options?.ownChat} preserveFocus=${options?.preserveFocus}`); return true; }, }), - upcastPartial({ executeCommand: async (id: string) => { calls.push(id); return undefined; } }), )); await service.presenter.get().openComposer(undefined, { preserveFocus: true }); - const withoutTarget = calls.splice(0); await service.presenter.get().openComposer({ folderUri: URI.file('/repo.worktrees/a'), providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }); - assert.deepStrictEqual({ withoutTarget, withTarget: calls }, { - withoutTarget: ['reveal preserveFocus=true', ACTION_ID_NEW_CHAT], - withTarget: ['reveal preserveFocus=undefined', ACTION_ID_NEW_CHAT, 'newChat /repo.worktrees/a provider=local-agent-host type=copilotcli'], - }); + assert.deepStrictEqual(calls, [ + 'newChat undefined provider=undefined type=undefined ownChat=true preserveFocus=true', + 'newChat /repo.worktrees/a provider=local-agent-host type=copilotcli ownChat=true preserveFocus=undefined', + ]); }); test('the presenter registered last presents, until its registration is disposed, also when the same presenter is registered twice', () => { - const service = disposables.add(new EditorWindowSessionsPresenterService(upcastPartial({}), upcastPartial({}))); + const service = disposables.add(new EditorWindowSessionsPresenterService(upcastPartial({}))); const chatView = service.presenter.get(); const first = upcastPartial({}); const second = upcastPartial({}); @@ -162,7 +161,7 @@ suite('EditorWindowSessionsPresenterService', () => { }); test('counts the composers of what holds them, also once it no longer presents the sessions, until it no longer holds them', async () => { - const service = disposables.add(new EditorWindowSessionsPresenterService(upcastPartial({}), upcastPartial({}))); + const service = disposables.add(new EditorWindowSessionsPresenterService(upcastPartial({}))); const closed: string[] = []; const presenter = service.registerPresenter(upcastPartial({})); const holder = service.registerComposerHolder({ hasComposer: () => true, closeIdleComposers: async () => { closed.push('chat part'); } }); @@ -184,7 +183,6 @@ suite('EditorWindowSessionsService', () => { const calls: string[] = []; const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IChatViewHostService, upcastPartial({ reveal: async () => { calls.push('reveal'); return undefined; }, newChat: async () => { calls.push('newChat'); return true; } })); - instantiationService.stub(ICommandService, upcastPartial({ executeCommand: async (id: string) => { calls.push(id); return undefined; } })); const presenterService = disposables.add(instantiationService.createInstance(EditorWindowSessionsPresenterService)); instantiationService.stub(IEditorWindowSessionsPresenterService, presenterService); instantiationService.stub(ISessionHost, upcastPartial({})); @@ -202,8 +200,44 @@ suite('EditorWindowSessionsService', () => { registration.dispose(); assert.deepStrictEqual({ withoutPresenter, withPresenter: calls }, { - withoutPresenter: ['reveal', ACTION_ID_NEW_CHAT], + withoutPresenter: ['newChat'], withPresenter: ['composer /repo model=model devContainer=true toSide=true'], }); }); + + test('trust is the caller\'s to ask for: canOpenSession asks for it, and opening a session\'s chat, also to the side, does not', async () => { + const calls: string[] = []; + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IChatViewHostService, upcastPartial({})); + const presenterService = disposables.add(instantiationService.createInstance(EditorWindowSessionsPresenterService)); + const registration = presenterService.registerPresenter(upcastPartial({ + openChat: async (_session, chatResource, options) => { calls.push(`open ${chatResource?.path} toSide=${options?.sideBySide}`); }, + })); + instantiationService.stub(IEditorWindowSessionsPresenterService, presenterService); + instantiationService.stub(ISessionHost, upcastPartial({})); + instantiationService.stub(ISessionsManagementService, upcastPartial({ getRemoteHostLabel: () => undefined })); + instantiationService.stub(IWorkspaceContextService, upcastPartial({ getWorkspace: () => upcastPartial>({ folders: [] }), getWorkspaceFolder: () => null })); + instantiationService.stub(IEditorService, upcastPartial({ activeEditor: undefined })); + instantiationService.stub(IWorkspaceTrustManagementService, upcastPartial({ getUriTrustInfo: async uri => ({ uri, trusted: false }) })); + instantiationService.stub(IWorkspaceTrustRequestService, upcastPartial({ requestResourcesTrust: async () => { calls.push('request trust'); return false; } })); + const service = disposables.add(instantiationService.createInstance(EditorWindowSessionsService)); + const folder = URI.file('/untrusted'); + const workspace: ISessionWorkspace = { uri: folder, label: 'untrusted', icon: undefined!, requiresWorkspaceTrust: true, isVirtualWorkspace: false, folders: [{ root: folder, workingDirectory: folder, name: 'untrusted', description: undefined, gitRepository: undefined }] }; + const built = buildTestSession({ id: 'untrusted', title: 'Untrusted' }); + const session: ISession = { ...built.session, workspace: constObservable(workspace) }; + const chat = built.mainChat.chat.resource; + + await service.openSessionToSide(session, { chatResource: chat }); + await service.openChat(session, chat); + await service.openChatToSide(session, chat); + const opened = calls.splice(0); + const canOpen = await service.canOpenSession(session); + registration.dispose(); + + assert.deepStrictEqual({ opened, canOpen, asked: calls }, { + opened: [`open ${chat.path} toSide=true`, `open ${chat.path} toSide=undefined`, `open ${chat.path} toSide=true`], + canOpen: false, + asked: ['request trust'], + }); + }); }); diff --git a/src/vs/workbench/test/browser/layout.test.ts b/src/vs/workbench/test/browser/layout.test.ts index 48890111b6c0e5..22a89d92b9d208 100644 --- a/src/vs/workbench/test/browser/layout.test.ts +++ b/src/vs/workbench/test/browser/layout.test.ts @@ -15,7 +15,9 @@ import { Layout } from '../../browser/layout.js'; import { Part, updateSashEndpoints } from '../../browser/part.js'; import { PanelAlignment, Parts, Position } from '../../services/layout/browser/layoutService.js'; import { NullTelemetryService } from '../../../platform/telemetry/common/telemetryUtils.js'; -import { ViewContainerLocation } from '../../common/views.js'; +import { Extensions as ViewExtensions, IViewContainersRegistry, IViewsRegistry, ViewContainerLocation } from '../../common/views.js'; +import { Registry } from '../../../platform/registry/common/platform.js'; +import { SyncDescriptor } from '../../../platform/instantiation/common/descriptors.js'; suite('Workbench layout sash endpoints', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -445,6 +447,25 @@ suite('Workbench layout chat part', () => { }); }); + test('the chat part goes back to the primary side bar\'s side as its anchor view is deregistered', () => { + const { layout, anchor } = createGridLayout({ sideBar: Position.LEFT, panel: Position.BOTTOM, alignment: 'center', chat: { visible: true, size: 400 }, anchor: ViewContainerLocation.AuxiliaryBar }); + Object.assign(layout, { viewDescriptorService: { ...Reflect.get(layout, 'viewDescriptorService'), onDidChangeContainerLocation: Event.None, onDidChangeContainer: Event.None, onDidChangeLocation: Event.None, onDidChangeViewContainers: Event.None } }); + const listener = (Reflect.get(layout, 'onDidChangeChatPartAnchorLocation') as () => Event).call(layout)(() => (Reflect.get(layout, 'updateChatPartPosition') as () => void).call(layout)); + const viewContainer = Registry.as(ViewExtensions.ViewContainersRegistry).registerViewContainer({ id: 'layoutTest.anchorContainer', title: { value: 'Anchor', original: 'Anchor' }, ctorDescriptor: new SyncDescriptor(class { }) }, ViewContainerLocation.AuxiliaryBar); + const views = [{ id: 'sessions', name: { value: 'Sessions', original: 'Sessions' }, ctorDescriptor: new SyncDescriptor(class { }) }]; + const viewsRegistry = Registry.as(ViewExtensions.ViewsRegistry); + viewsRegistry.registerViews(views, viewContainer); + const registered = layout.getChatPartPosition(); + // Gone, the view has no location + anchor.location = null; + viewsRegistry.deregisterViews(views, viewContainer); + const deregistered = layout.getChatPartPosition(); + listener.dispose(); + Registry.as(ViewExtensions.ViewContainersRegistry).deregisterViewContainer(viewContainer); + + assert.deepStrictEqual({ registered, deregistered }, { registered: Position.RIGHT, deregistered: Position.LEFT }); + }); + test('the chat part follows its anchor view as the view moves, the side bar flips and the panel moves', () => { const { layout, middle, anchor } = createGridLayout({ sideBar: Position.LEFT, panel: Position.BOTTOM, alignment: 'center', chat: { visible: true, size: 400 }, anchor: ViewContainerLocation.AuxiliaryBar }); const setSideBarPosition = (position: Position) => (Reflect.get(layout, 'setSideBarPosition') as (position: Position) => void).call(layout, position); diff --git a/src/vs/workbench/test/browser/parts/auxiliaryBarPart.test.ts b/src/vs/workbench/test/browser/parts/auxiliaryBarPart.test.ts index e1d98667373755..df0c03c913d282 100644 --- a/src/vs/workbench/test/browser/parts/auxiliaryBarPart.test.ts +++ b/src/vs/workbench/test/browser/parts/auxiliaryBarPart.test.ts @@ -9,6 +9,10 @@ import { ToggleMenuAction } from '../../../../base/browser/ui/toolbar/toolbar.js import { Separator } from '../../../../base/common/actions.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { createTestAuxiliaryBarPart } from './auxiliaryBarTestUtils.js'; +import { isIMenuItem, MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js'; +import { Context } from '../../../../platform/contextkey/browser/contextKeyService.js'; +import { AuxiliaryBarMaximizedContext, AuxiliaryBarMaximizeHiddenContext } from '../../../common/contextkeys.js'; +import '../../../browser/parts/auxiliarybar/auxiliaryBarActions.js'; import '../../../browser/media/part.css'; import '../../../contrib/modernUI/browser/media/padding.css'; @@ -65,3 +69,24 @@ suite('Auxiliary Bar - Responsive title actions', () => { }); } }); + +suite('Auxiliary Bar - Maximize action', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('the title offers Maximize unless its view container hides it, and Restore while maximized, also where Maximize is hidden', () => { + const when = MenuRegistry.getMenuItems(MenuId.AuxiliaryBarTitle).filter(isIMenuItem).find(item => item.command.id === 'workbench.action.toggleMaximizedAuxiliaryBar')?.when; + const shows = (maximizeHidden: boolean, maximized: boolean) => { + const context = new Context(1, null); + context.setValue(AuxiliaryBarMaximizeHiddenContext.key, maximizeHidden); + context.setValue(AuxiliaryBarMaximizedContext.key, maximized); + return !!when?.evaluate(context); + }; + + assert.deepStrictEqual({ + maximize: shows(false, false), + restore: shows(false, true), + maximizeHidden: shows(true, false), + restoreWhileMaximizeHidden: shows(true, true), + }, { maximize: true, restore: true, maximizeHidden: false, restoreWhileMaximizeHidden: true }); + }); +}); From 8459c200528bd3e6148673a983a721a3008e4910 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:06:50 +0100 Subject: [PATCH 067/165] chat: a Chat view that shows only the sessions focuses its sessions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/widgetHosts/viewPane/chatViewPane.ts | 5 +++++ .../widgetHosts/viewPane/chatViewPane.test.ts | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 7c47797d1a65a9..bf47f77ecfa17c 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -1873,6 +1873,11 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } focusInput(): void { + // Showing only the sessions, the Chat view has no input of its own: its sessions take focus + if (this.providedSessionsList?.sessionsOnly.get()) { + this.sessionsControl?.focus(); + return; + } if (this.composerShown && this.providedComposer) { this.providedComposer.focus(); return; diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index d57a9498943d8d..f4d195000174ea 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -149,6 +149,22 @@ suite('ChatViewPane sessions refresh', () => { assert.deepStrictEqual({ results, calls }, { results: { shown: true, hidden: false, withoutComposer: false }, calls: ['cancel load', 'cancel restore', 'newChat', 'focus', 'newChat'] }); }); + test('focusing the input of a Chat view that shows only the sessions focuses its sessions, else the composer that shows, else the chat', () => { + const focused: string[] = []; + const focusInput = Reflect.get(ChatViewPane.prototype, 'focusInput') as (this: object) => void; + const host = (sessionsOnly: boolean, composerShown: boolean) => ({ + providedSessionsList: { sessionsOnly: constObservable(sessionsOnly) }, + sessionsControl: { focus: () => focused.push('sessions') }, + providedComposer: { focus: () => focused.push('composer') }, + composerShown, + _widget: { focusInput: () => focused.push('chat') }, + }); + focusInput.call(host(true, true)); + focusInput.call(host(false, true)); + focusInput.call(host(false, false)); + assert.deepStrictEqual(focused, ['sessions', 'composer', 'chat']); + }); + test('as New Chat clears a chat with focus in the Chat view, the composer that shows in its place takes focus', () => { const focused: string[] = []; const element = mainWindow.document.body.appendChild(document.createElement('div')); From 5d673502961f08c8498d07218159a5261ef95019 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:10:11 +0100 Subject: [PATCH 068/165] Chat window composer: a composer that follows its host to another folder starts a new target generation A background send that fails after the composer followed its host to another folder then offers its prompt back, rather than restoring it silently into the composer aimed at the new folder. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/chatEditor/chatWindowComposer.ts | 2 ++ .../chatEditor/chatWindowComposer.test.ts | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts b/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts index 60bf70820559db..9cc337c6d2b971 100644 --- a/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts +++ b/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts @@ -318,6 +318,8 @@ export class ChatWindowComposerModel extends Disposable implements INewSessionDr const retargeted = { ...target, folderUri }; this._hostFolder = folderUri; this._requestedTarget.set(retargeted, undefined); + // As a target the user picks: a background send that fails then offers its prompt back, not into another folder silently + this._targetGeneration++; this._onDidRetarget.fire(retargeted); return true; } diff --git a/src/vs/workbench/contrib/sessions/test/browser/chatEditor/chatWindowComposer.test.ts b/src/vs/workbench/contrib/sessions/test/browser/chatEditor/chatWindowComposer.test.ts index 1198ebf4aec826..90bb5a385a1e73 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/chatEditor/chatWindowComposer.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/chatEditor/chatWindowComposer.test.ts @@ -514,6 +514,29 @@ suite('Chat window composer', () => { }); }); + test('a background send that fails once its composer followed its host to another folder offers its prompt back, rather than restoring it silently there', () => { + const store = disposables.add(new DisposableStore()); + const { events, onDidEnd, slots, createComposer } = createServices(store); + const composer = store.add(createComposer()); + const { slot } = slots[0]; + events.length = 0; + // Sent in the background: the composer records what was written, clears its input, and its request is accepted + const attempt = composer.input.beginSendAttempt({ inputText: 'fix it', attachments: [] }); + composer.input.set({ inputText: '', attachments: [] }); + const session = createSession('sent', { folder: '/repo' }); + composer.trackBackgroundSend({ session, query: 'fix it', attachedContext: undefined, requestedInBackground: true, isLatest: () => true }); + composer.input.endSendAttempt(attempt); + + const followed = composer.retargetFolder(URI.file('/repo.worktrees/a'), URI.file('/repo')); + onDidEnd.fire({ session, owner: slot, started: false, readopted: false, error: new Error('no agent') }); + + assert.deepStrictEqual({ followed, input: composer.input.get().input.inputText, events }, { + followed: true, + input: '', + events: ['notify: Couldn\'t start the session in repo with type: no agent [Restore Prompt, Copy Prompt]'], + }); + }); + test('a composer does not follow its host to another folder while it sends: from the user\'s submit, before its request is accepted, until the request ends', () => { const store = disposables.add(new DisposableStore()); const { onDidAccept, onDidEnd, slots, createComposer } = createServices(store); From 49b6db7fa6c73e5a3853e24b18485923413d3b0e Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:10:55 +0100 Subject: [PATCH 069/165] Sessions runtime: wait for a session's chats among every session, whatever their resources waitForAgentsViewSession observes every session the providers know, also outside their listing scope, through the new provider-neutral ISessionsManagementService.getAllSessions, and the chats of each, also of a session without several chats, such as a subagent's chat. It no longer derives a chat's session from the chat's resource, which providers need not relate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/browser/sessionNavigation.test.ts | 1 + .../browser/agentsView/agentsViewProviders.ts | 43 ++++------------ .../editorWindowSessionsNavigationHost.ts | 2 +- .../agentsView/editorWindowSessionsService.ts | 2 +- .../browser/sessionsManagementService.ts | 4 ++ .../sessions/common/sessionsManagement.ts | 6 +++ .../agentsView/agentsViewProviders.test.ts | 50 ++++++++++--------- 7 files changed, 49 insertions(+), 59 deletions(-) diff --git a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts index eaa94e253b8a27..a8cebecde2ce64 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts @@ -175,6 +175,7 @@ class MockSessionStore implements ISessionsManagementService { } getSessions(): ISession[] { return [...this._sessions.values()]; } + getAllSessions(): ISession[] { return this.getSessions(); } getInFlightNewSessionRequests(): readonly ISession[] { return []; } getInFlightNewSessionRequest(): undefined { return undefined; } diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsViewProviders.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsViewProviders.ts index 276467f27c7ec6..623d1f8fb4e543 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsViewProviders.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsViewProviders.ts @@ -6,7 +6,6 @@ import { raceTimeout } from '../../../../../base/common/async.js'; import { DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; import { autorun, observableSignal } from '../../../../../base/common/observable.js'; -import { URI } from '../../../../../base/common/uri.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { ISessionsManagementService } from '../../common/sessionsManagement.js'; @@ -27,11 +26,10 @@ export interface IAgentsViewProvidersService { export const AGENTS_VIEW_SESSION_WAIT_TIMEOUT = 5_000; /** - * What `find` finds among the sessions and their chats, once it does, or `undefined` once `timeout` passed. `resource` is - * what it looks for, a session or a chat: a peer chat of a session is the session's resource with the chat's id as its - * fragment, so the session it may appear in is known by lookup, also outside a provider's listing scope. + * What `find` finds among the sessions and their chats, once it does, or `undefined` once `timeout` passed: among every + * session the providers know, also outside their listing scope, and the chats they publish, whatever their resources. */ -export async function waitForAgentsViewSession(sessionsManagementService: ISessionsManagementService, resource: URI, find: () => T | undefined, timeout = AGENTS_VIEW_SESSION_WAIT_TIMEOUT): Promise { +export async function waitForAgentsViewSession(sessionsManagementService: ISessionsManagementService, find: () => T | undefined, timeout = AGENTS_VIEW_SESSION_WAIT_TIMEOUT): Promise { const found = find(); if (found) { return found; @@ -39,37 +37,16 @@ export async function waitForAgentsViewSession(sessionsManagementService: ISe const store = new DisposableStore(); try { return await raceTimeout(new Promise(resolve => { - // The sessions it knows of, by their ID: those listed, and those that change, also outside a provider's listing - // scope, such as of another folder, which an open can find - const sessions = new Map(sessionsManagementService.getSessions().map(session => [session.sessionId, session])); + // Every session the providers know, also outside their listing scope, such as of another folder, which an open can + // find; their changes, also of those outside the scope, can add the one it looks for const sessionsChanged = observableSignal('waitForAgentsViewSession'); - store.add(sessionsManagementService.onDidChangeSession(changes => { - for (const { session, kind } of changes) { - if (kind === 'removed') { - sessions.delete(session.sessionId); - } else { - sessions.set(session.sessionId, session); - } - } - sessionsChanged.trigger(undefined); - })); + store.add(sessionsManagementService.onDidChangeSession(() => sessionsChanged.trigger(undefined))); store.add(autorun(reader => { sessionsChanged.read(reader); - // The session of `resource`, or whose peer chat it is, which a provider can know without listing it or - // reporting a change of it as it publishes the chat - const candidates = new Map(sessions); - for (const lookup of [resource, resource.with({ fragment: '' })]) { - const session = sessionsManagementService.getSession(lookup); - if (session) { - candidates.set(session.sessionId, session); - } - } - // A session can list a chat after it is listed itself, such as a peer chat its chats publish later: only a - // session that has several chats can - for (const session of candidates.values()) { - if (session.capabilities.read(reader).supportsMultipleChats) { - session.chats.read(reader); - } + // A session can list a chat after it is known itself, without a change of it: a peer chat or a subagent's, + // also of a session without several chats, whose resource need not be derived from the session's + for (const session of sessionsManagementService.getAllSessions()) { + session.chats.read(reader); } const value = find(); if (value) { diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsNavigationHost.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsNavigationHost.ts index bf6f09d5bec412..9d2a7f0dc99307 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsNavigationHost.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsNavigationHost.ts @@ -51,7 +51,7 @@ export class EditorWindowSessionsNavigationHost implements ISessionsNavigationSe try { // Providers redirect superseded resources, such as of a session they adopted, and leave unfamiliar ones unchanged const resolved = await this._sessionsManagementService.resolveSessionResource(sessionResource, 'open'); - const session = await waitForAgentsViewSession(this._sessionsManagementService, resolved, () => this._sessionsManagementService.getSession(resolved)); + const session = await waitForAgentsViewSession(this._sessionsManagementService, () => this._sessionsManagementService.getSession(resolved)); if (session) { await this._presenter.openChat(session, undefined, { preserveFocus: options?.preserveFocus }); } diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsService.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsService.ts index 745f1a902d57e7..d15997bf29f1cb 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsService.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsService.ts @@ -131,7 +131,7 @@ export class EditorWindowSessionsService extends Disposable implements ISessions try { // Providers redirect superseded resources, such as of a session they adopted, and leave unfamiliar ones unchanged const resolved = await this._sessionsManagementService.resolveSessionResource(sessionResource, 'open'); - const session = await waitForAgentsViewSession(this._sessionsManagementService, resolved, () => this._sessionsManagementService.getSession(resolved)); + const session = await waitForAgentsViewSession(this._sessionsManagementService, () => this._sessionsManagementService.getSession(resolved)); if (session) { await this._openSession(session, options); } diff --git a/src/vs/workbench/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/workbench/contrib/sessions/browser/sessionsManagementService.ts index acc075754dbd3d..658a917e224fab 100644 --- a/src/vs/workbench/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/workbench/contrib/sessions/browser/sessionsManagementService.ts @@ -410,6 +410,10 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } /** Every session, also those outside a provider's listing scope, for looking sessions up. */ + getAllSessions(): readonly ISession[] { + return this._getAllSessions(); + } + private _getAllSessions(): ISession[] { const sessions: ISession[] = []; for (const provider of this.sessionsProvidersService.getProviders({ includeSessionBackends: true })) { diff --git a/src/vs/workbench/contrib/sessions/common/sessionsManagement.ts b/src/vs/workbench/contrib/sessions/common/sessionsManagement.ts index 557c081ce8d0ff..2360d49ce7a40b 100644 --- a/src/vs/workbench/contrib/sessions/common/sessionsManagement.ts +++ b/src/vs/workbench/contrib/sessions/common/sessionsManagement.ts @@ -301,6 +301,12 @@ export interface ISessionsManagementService { */ getSession(resource: URI): ISession | undefined; + /** + * Every session the providers know, also those outside their listing scope, such as of another folder: not only the + * listed ones of {@link getSessions}. {@link onDidChangeSession} reports their changes. + */ + getAllSessions(): readonly ISession[]; + /** * Resolves a session resource to the one that should actually be opened. * Open paths address sessions by URI, so a superseded resource (a legacy diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsViewProviders.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsViewProviders.test.ts index 0a68bbd2c60cf3..59ef8e27051d26 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsViewProviders.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsViewProviders.test.ts @@ -15,6 +15,7 @@ import { IChat, ISession } from '../../../../../services/sessions/common/session import { AGENTS_VIEW_SESSION_WAIT_TIMEOUT, waitForAgentsViewSession } from '../../../browser/agentsView/agentsViewProviders.js'; import { ISessionChange, ISessionsManagementService } from '../../../common/sessionsManagement.js'; import { buildTestSession } from '../../common/testSessionBuilder.js'; +import { URI } from '../../../../../../base/common/uri.js'; suite('waitForAgentsViewSession', () => { @@ -27,39 +28,40 @@ suite('waitForAgentsViewSession', () => { const parent: ISession = { ...built.session, chats }; const sessionsManagementService = upcastPartial({ onDidChangeSession: Event.None, - getSessions: () => [parent], - getSession: () => undefined, + getAllSessions: () => [parent], }); const find = () => parent.chats.get().find(chat => isEqual(chat.resource, peer.resource)); - const found = waitForAgentsViewSession(sessionsManagementService, peer.resource, find); + const found = waitForAgentsViewSession(sessionsManagementService, find); await timeout(1000); chats.set([built.mainChat.chat, peer], undefined); assert.strictEqual(await found, peer); })); - test('finds a peer chat that a session outside the listing scope, known before the wait, publishes without a change of the sessions', () => runWithFakedTimers({}, async () => { - const built = buildTestSession({ id: 'parent', title: 'Parent' }); - const peerResource = built.session.resource.with({ fragment: 'peer' }); - const peer: IChat = { ...built.mainChat.chat, resource: peerResource }; - const chats = observableValue('chats', [built.mainChat.chat]); - const parent: ISession = { ...built.session, chats, capabilities: constObservable({ ...built.session.capabilities.get(), supportsMultipleChats: true }) }; - const sessionsManagementService = upcastPartial({ - // Not listed, and no change of it is reported: only its lookup finds it - onDidChangeSession: Event.None, - getSessions: () => [], - getSession: resource => isEqual(resource, parent.resource) ? parent : undefined, - }); - const find = () => parent.chats.get().find(chat => isEqual(chat.resource, peerResource)); + for (const peerKind of ['peer', 'subagent'] as const) { + test(`finds a ${peerKind} chat with a resource unrelated to its session's that a session outside the listing scope, known before the wait, publishes without a change of the sessions${peerKind === 'subagent' ? ', also without several chats' : ''}`, () => runWithFakedTimers({}, async () => { + const built = buildTestSession({ id: 'parent', title: 'Parent' }); + const peerResource = URI.parse(`other-scheme:/${peerKind}-chat`); + const peer: IChat = { ...built.mainChat.chat, resource: peerResource }; + const chats = observableValue('chats', [built.mainChat.chat]); + const parent: ISession = { ...built.session, chats, capabilities: constObservable({ ...built.session.capabilities.get(), supportsMultipleChats: peerKind === 'peer' }) }; + const sessionsManagementService = upcastPartial({ + // Not listed, and no change of it is reported: only the providers know it + onDidChangeSession: Event.None, + getSessions: () => [], + getAllSessions: () => [parent], + }); + const find = () => parent.chats.get().find(chat => isEqual(chat.resource, peerResource)); - const start = Date.now(); - const found = waitForAgentsViewSession(sessionsManagementService, peerResource, find); - await timeout(1000); - chats.set([built.mainChat.chat, peer], undefined); + const start = Date.now(); + const found = waitForAgentsViewSession(sessionsManagementService, find); + await timeout(1000); + chats.set([built.mainChat.chat, peer], undefined); - assert.deepStrictEqual({ found: await found, beforeTimeout: Date.now() - start < AGENTS_VIEW_SESSION_WAIT_TIMEOUT }, { found: peer, beforeTimeout: true }); - })); + assert.deepStrictEqual({ found: await found, beforeTimeout: Date.now() - start < AGENTS_VIEW_SESSION_WAIT_TIMEOUT }, { found: peer, beforeTimeout: true }); + })); + } test('finds a session that its provider does not list, such as of another folder, as soon as the provider knows it', () => runWithFakedTimers({}, async () => { const elsewhere = buildTestSession({ id: 'elsewhere', title: 'Elsewhere' }).session; @@ -68,11 +70,11 @@ suite('waitForAgentsViewSession', () => { const sessionsManagementService = upcastPartial({ onDidChangeSession: sessionChanged.event, getSessions: () => [], - getSession: () => undefined, + getAllSessions: () => allSessions, }); const start = Date.now(); - const found = waitForAgentsViewSession(sessionsManagementService, elsewhere.resource, () => allSessions.find(session => isEqual(session.resource, elsewhere.resource))); + const found = waitForAgentsViewSession(sessionsManagementService, () => allSessions.find(session => isEqual(session.resource, elsewhere.resource))); await timeout(10); allSessions.push(elsewhere); sessionChanged.fire([{ session: elsewhere, kind: 'added' }]); From 57c4581190f9c08cfc077c053bdb53bc5216b8ed Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:11:18 +0100 Subject: [PATCH 070/165] Chat part host: the wait for a session takes no resource Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/sessions/browser/agentsView/agentsChatViewHost.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts index caec79ec2a536f..03bcfded0914a7 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts @@ -127,7 +127,7 @@ export class AgentsChatViewHost implements IChatViewHost { const generation = this._generation; const hold = this._providers.acquire(); try { - const owner = await waitForAgentsViewSession(this._sessionsManagementService, sessionResource, () => this._sessionsManagementService.getSessionForChatResource(sessionResource)); + const owner = await waitForAgentsViewSession(this._sessionsManagementService, () => this._sessionsManagementService.getSessionForChatResource(sessionResource)); if (!owner || generation !== this._generation) { return undefined; } From 9ce6adce9cb921df2ff9e75196b02991336260d9 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:15:53 +0100 Subject: [PATCH 071/165] Chat view host service: keep the claim guard of #348302 for removed hosts; test mocks list every session Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/widgetHosts/viewPane/chatViewHostService.ts | 4 ++++ .../test/browser/agentsView/agentsChatViewHost.test.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts index 69f216f62dde95..6e24772fe32e60 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts @@ -189,6 +189,10 @@ export class ChatViewHostService extends Disposable implements IChatViewHostServ /** Makes `host` the host that showed something last: a new chat of New Chat while `newChat`. */ private claim(host: IChatViewHost, newChat = false): void { + // A host no longer registered, such as one that completes a reveal after it was removed, shows nothing of the Chat view's + if (host !== this.chatViewHost && !this.registrations.some(registration => registration.host === host)) { + return; + } this.latest.set({ host, newChat }, undefined); this.updateChatViewTracking(); } diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts index 1abf4f723cf49f..87bf60a9de0c4d 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts @@ -57,7 +57,7 @@ suite('AgentsChatViewHost', () => { const host = new AgentsChatViewHost( constObservable(options.ownsNewChat ?? true), chatPart, - upcastPartial({ getSessionForChatResource: () => undefined, getSession: () => undefined, getAllSessionTypes: () => [], getSessions: () => [], onDidChangeSession: () => toDisposable(() => { }), ...options.sessionsManagementService }), + upcastPartial({ getSessionForChatResource: () => undefined, getSession: () => undefined, getAllSessionTypes: () => [], getSessions: () => [], getAllSessions: () => [], onDidChangeSession: () => toDisposable(() => { }), ...options.sessionsManagementService }), upcastPartial({ getAllWidgets: () => options.widgets ?? [] }), upcastPartial({ isVisible: () => false }), upcastPartial({ getWorkspace: () => upcastPartial>({ folders: [] }) }), From 20aa95ffd2fe7d086e30744088167fc4787674c8 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:16:46 +0100 Subject: [PATCH 072/165] Worktree infra: a worktree's admin back-link must name the checkout itself, and an open through the Chat view host reports its chat once - The location provider accepts a candidate only where the folder of the .git file its entry names is the candidate, by real paths, so another folder whose .git links to the same file does not inherit the primary's trust. - ChatViewPaneHost reports a chat after loading it only where the load reported nothing, such as the chat it shows already. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostWorktreeLocations.ts | 12 ++-- .../viewPane/chatViewHostService.ts | 7 ++- .../agentHostWorktreeLocations.test.ts | 58 ++++++++++++++++--- .../widgetHosts/viewPane/chatViewPane.test.ts | 50 +++++++++++++++- 4 files changed, 111 insertions(+), 16 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts index c41433318306f6..2e51fd0ea2717f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts @@ -124,8 +124,8 @@ export class AgentHostWorktreeLocationProvider extends Disposable implements IWo /** * `location` as a location of the folder at `primary`, when git links it both ways with an entry of `worktreesDir`: its - * `.git` file names the entry, and the entry's `gitdir` file names its `.git` back, by their real paths, such as of a - * folder reached through a symbolic link. + * `.git` file names the entry, and the entry's `gitdir` file names the location's `.git` back, by their real paths, such + * as of a folder reached through a symbolic link. */ private async _resolve(worktreesDir: URI, primary: URI, location: URI): Promise { if (location.scheme !== Schemas.file) { @@ -141,8 +141,12 @@ export class AgentHostWorktreeLocationProvider extends Disposable implements IWo return undefined; } const backlink = await this._readLink(joinPath(entry, 'gitdir'), entry); - // The `.git` file by its real path, also where it is a symbolic link - if (!backlink || !extUriBiasedIgnorePathCase.isEqual(await this._real(backlink), await this._real(locationGitFile))) { + // The entry names the checkout itself: the folder of the `.git` file it names is the location, by their real paths, + // and that file is the location's `.git`, also where it is a symbolic link. Another folder whose `.git` links to the + // same file is no checkout of the entry, and must not inherit the primary's trust. + if (!backlink + || !extUriBiasedIgnorePathCase.isEqual(await this._real(dirname(backlink)), realLocation) + || !extUriBiasedIgnorePathCase.isEqual(await this._real(backlink), await this._real(locationGitFile))) { return undefined; } // Its branch, from the entry's HEAD: read again each time the location is resolved diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts index 852d04abf0ebfc..2e0d3fbb2c7827 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts @@ -61,9 +61,12 @@ export class ChatViewPaneHost implements IChatViewHost { const chatView = await this.viewsService.openView(ChatViewId, !options?.preserveFocus); if (chatView) { const reason = getChatViewHostOpenReason(options); + const shownBefore = chatView.shownSession.get(); await chatView.loadSession(sessionResource, options?.sessionTypeSelectionReason, reason); - // Also a chat it showed already, which loading does not set again - chatView.reportShown(sessionResource, reason); + // A chat it showed already, which loading does not set again, is reported once: a chat that loading set is reported already + if (chatView.shownSession.get() === shownBefore) { + chatView.reportShown(sessionResource, reason); + } if (!options?.preserveFocus) { chatView.focusInput(); } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts index 5596642977a05a..4a6375469149e0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts @@ -8,13 +8,16 @@ import { DeferredPromise } from '../../../../../../../base/common/async.js'; import { VSBuffer } from '../../../../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../../../../base/common/cancellation.js'; import { Emitter } from '../../../../../../../base/common/event.js'; +import { extUri } from '../../../../../../../base/common/resources.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { FileOperationError, FileOperationResult, IFileContent, IFileService, IFileStatWithMetadata } from '../../../../../../../platform/files/common/files.js'; import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../../platform/log/common/log.js'; -import { AgentHostWorktreeLocationProvider, getAgentHostSessionWorkingDirectories } from '../../../../browser/agentSessions/agentHost/agentHostWorktreeLocations.js'; +import { WorkbenchState } from '../../../../../../../platform/workspace/common/workspace.js'; +import { BrowserWorkspaceEditingService } from '../../../../../../services/workspaces/browser/workspaceEditingService.js'; +import { AGENT_HOST_WORKTREE_LOCATIONS_PROVIDER_ID, AgentHostWorktreeLocationProvider, getAgentHostSessionWorkingDirectories } from '../../../../browser/agentSessions/agentHost/agentHostWorktreeLocations.js'; import { IAgentSession } from '../../../../browser/agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../../../../browser/agentSessions/agentSessionsService.js'; import { IChatSessionsService, SessionType } from '../../../../common/chatSessionsService.js'; @@ -186,17 +189,58 @@ suite('AgentHostWorktreeLocations', () => { }, { throughLinkedPrimary: 'feature', linkedWorktree: 'feature', primaryItself: 'none', offered: ['/alias/feature'] }); }); - test('follows symbolic links of the repository\'s worktrees folder and of a worktree\'s .git file, and still rejects one that git does not link back', async () => { - // The repository's `.git/worktrees` is a link to another folder, and the worktree's `.git` file a link to another file + test('follows symbolic links of the repository\'s worktrees folder and of a worktree\'s .git file, and grants the primary\'s trust only to the checkout git links back, not to another folder whose .git links to the same file', async () => { + // The repository's `.git/worktrees` is a link to another folder, and the worktree's `.git` file a link to another + // file; git names the worktree's own `.git` back. The impostor's `.git` links to the same file. const linked = { '/work/vscode/.git': true, - '/store/worktrees/feature/gitdir': '/store/feature.git\n', + '/store/worktrees/feature/gitdir': '/work/vscode.worktrees/feature/.git\n', '/store/feature.git': 'gitdir: /work/vscode/.git/worktrees/feature\n', - '/work/impostor/.git': 'gitdir: /work/vscode/.git/worktrees/feature\n', }; - const provider = createProvider(linked, [], [], { '/work/vscode/.git/worktrees': '/store/worktrees', '/work/vscode.worktrees/feature/.git': '/store/feature.git' }); + const provider = createProvider(linked, [], [], { + '/work/vscode/.git/worktrees': '/store/worktrees', + '/work/vscode.worktrees/feature/.git': '/store/feature.git', + '/work/impostor/.git': '/store/feature.git', + }); const resolve = async (path: string) => (await provider.resolveLocation(URI.file('/work/vscode'), URI.file(path), CancellationToken.None))?.uri.path ?? 'none'; - assert.deepStrictEqual({ linked: await resolve('/work/vscode.worktrees/feature'), impostor: await resolve('/work/impostor') }, { linked: '/work/vscode.worktrees/feature', impostor: 'none' }); + // The workspace grants a location of a built-in provider the trust of the primary location + const primary = URI.file('/work/vscode'); + const trusted: string[] = []; + let current = primary; + const workspaceEditingService = store.add(new (BrowserWorkspaceEditingService as unknown as new (...args: unknown[]) => BrowserWorkspaceEditingService)( + undefined, + { + getWorkbenchState: () => WorkbenchState.FOLDER, + getWorkspace: () => ({ folders: [{ uri: current }], uri: primary }), + setFolderLocation: async (location: URI, o?: { onApplied?: () => void }) => { current = location; o?.onApplied?.(); return true; }, + }, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, + { confirm: async () => ({ confirmed: false }) }, + undefined, + { extUri }, + { isWorkspaceTrusted: () => true, getUriTrustInfo: async (uri: URI) => ({ trusted: extUri.isEqual(uri, primary) }), setUrisTrust: async (uris: URI[]) => { trusted.push(...uris.map(uri => uri.path)); } }, + undefined, undefined, undefined, + { + canSetLocation: () => true, + resolveLocation: async (folder: URI, location: URI) => { + const resolved = await provider.resolveLocation(folder, location, CancellationToken.None); + return resolved && { ...resolved, providerId: AGENT_HOST_WORKTREE_LOCATIONS_PROVIDER_ID, builtIn: true }; + }, + setLocation: () => { }, + }, + { isWorkspaceTrustEnabled: () => true } + )); + await workspaceEditingService.setFolderLocation(URI.file('/work/impostor')); + const impostorTrusted = [...trusted]; + await workspaceEditingService.setFolderLocation(primary); + await workspaceEditingService.setFolderLocation(URI.file('/work/vscode.worktrees/feature')); + + assert.deepStrictEqual({ linked: await resolve('/work/vscode.worktrees/feature'), impostor: await resolve('/work/impostor'), impostorTrusted, trusted }, { + linked: '/work/vscode.worktrees/feature', + impostor: 'none', + impostorTrusted: [], + trusted: ['/work/vscode.worktrees/feature'], + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index 3d0ab1e601e7b2..b3c9889571b12f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -8,7 +8,11 @@ import { CancellationToken, CancellationTokenSource } from '../../../../../../.. import { CancellationError } from '../../../../../../../base/common/errors.js'; import { DeferredPromise, RunOnceScheduler, timeout } from '../../../../../../../base/common/async.js'; import { Emitter, Event } from '../../../../../../../base/common/event.js'; -import { constObservable, IObservable, observableValue } from '../../../../../../../base/common/observable.js'; +import { autorun, constObservable, IObservable, observableValue } from '../../../../../../../base/common/observable.js'; +import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IProgressService } from '../../../../../../../platform/progress/common/progress.js'; +import { IViewsService } from '../../../../../../services/views/common/viewsService.js'; +import { ChatViewHostService } from '../../../../browser/widgetHosts/viewPane/chatViewHostService.js'; import { DisposableStore, MutableDisposable, toDisposable } from '../../../../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { upcastPartial } from '../../../../../../../base/test/common/mock.js'; @@ -36,8 +40,8 @@ import { AgentSessionsFilter } from '../../../../browser/agentSessions/agentSess import { MenuWorkbenchToolBar } from '../../../../../../../platform/actions/browser/toolbar.js'; import { ChatConfiguration } from '../../../../common/constants.js'; import { AgentSessionsViewerOrientation } from '../../../../browser/agentSessions/agentSessions.js'; -import { Position } from '../../../../../../services/layout/browser/layoutService.js'; -import { ViewContainerLocation } from '../../../../../../common/views.js'; +import { IWorkbenchLayoutService, Position } from '../../../../../../services/layout/browser/layoutService.js'; +import { IViewDescriptorService, ViewContainerLocation } from '../../../../../../common/views.js'; suite('ChatViewPane sessions refresh', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -355,6 +359,46 @@ suite('ChatViewPane session transitions', () => { }); }); + test('through the Chat view host, each open reports the chat it shows exactly once: a chat it loads, the chat it shows already when picked again, to restore, and a committed one', async () => { + const rig = createPane(); + const savedResource = nextResource.with({ path: '/saved' }); + const committedResource = nextResource.with({ path: '/committed' }); + const reference = (resource: URI) => Object.assign(toDisposable(() => { }), { object: upcastPartial({ sessionResource: resource }) }); + Object.assign(rig.pane, { chatService: { acquireOrLoadSession: async (resource: URI) => reference(resource) } }); + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IViewsService, upcastPartial({ + openView: (async () => rig.pane) as IViewsService['openView'], + onDidChangeViewVisibility: Event.None, + getViewWithId: (() => rig.pane as T) as IViewsService['getViewWithId'], + })); + instantiationService.stub(IViewDescriptorService, upcastPartial({})); + instantiationService.stub(IWorkbenchLayoutService, upcastPartial({})); + instantiationService.stub(IProgressService, upcastPartial({})); + const host = instantiationService.createInstance(ChatViewHostService); + const stream: string[] = []; + store.add(autorun(reader => { + const shown = rig.pane.shownSession.read(reader); + stream.push(shown ? `${shown.resource.path} ${shown.reason}` : 'none'); + })); + const step = async (name: string, open: () => Promise) => { + const before = stream.length; + await open(); + return { [name]: stream.slice(before) }; + }; + + assert.deepStrictEqual(Object.assign({}, + await step('loaded', () => host.openSession(savedResource, { preserveFocus: true })), + await step('pickedAgain', () => host.openSession(savedResource, { preserveFocus: true })), + await step('restored', () => host.openSession(savedResource, { preserveFocus: true, restore: true })), + await step('committed', () => host.openSession(committedResource, { preserveFocus: true, commit: true })), + ), { + loaded: ['/saved open'], + pickedAgain: ['/saved open'], + restored: ['/saved restore'], + committed: ['/committed commit'], + }); + }); + test('cancelling a replacement releases its pending reference before provider activation completes', async () => { const resolving = new DeferredPromise(); const rig = createPane(resolving.p); From 1fbcf1a37efda4906e416d66003a2ffa734e05e9 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:19:09 +0100 Subject: [PATCH 073/165] Worktree infra: tests reach the trust consumer through a refused move, and Chat view mocks expose the shown session Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/test/browser/actions/chatMoveActions.test.ts | 2 ++ .../agentHost/agentHostWorktreeLocations.test.ts | 6 ++++-- .../chat/test/browser/widget/chatWidgetService.test.ts | 2 +- .../test/browser/widgetHosts/viewPane/chatViewPane.test.ts | 3 +++ 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatMoveActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatMoveActions.test.ts index c7c7c3901267e9..c841a1aea003d0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/actions/chatMoveActions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/actions/chatMoveActions.test.ts @@ -8,6 +8,7 @@ import { Event } from '../../../../../../base/common/event.js'; import { NullManagedSettingsService } from '../../../../../../platform/policy/common/copilotManagedSettings.js'; import { AccountPolicyGateState } from '../../../../../services/policies/common/accountPolicyService.js'; import { IReference, MutableDisposable, ReferenceCollection } from '../../../../../../base/common/lifecycle.js'; +import { constObservable } from '../../../../../../base/common/observable.js'; import { mockObject, upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; @@ -91,6 +92,7 @@ suite('Chat move actions', () => { return model; }, focusInput() { }, + shownSession: constObservable(undefined), reportShown() { }, }); const viewsService = upcastPartial({ diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts index 4a6375469149e0..fc7745c6093551 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts @@ -231,14 +231,16 @@ suite('AgentHostWorktreeLocations', () => { }, { isWorkspaceTrustEnabled: () => true } )); - await workspaceEditingService.setFolderLocation(URI.file('/work/impostor')); + // No provider knows the impostor: the workspace refuses to move there, and trusts nothing + const impostorMove = await workspaceEditingService.setFolderLocation(URI.file('/work/impostor')).then(() => 'moved', () => 'refused'); const impostorTrusted = [...trusted]; await workspaceEditingService.setFolderLocation(primary); await workspaceEditingService.setFolderLocation(URI.file('/work/vscode.worktrees/feature')); - assert.deepStrictEqual({ linked: await resolve('/work/vscode.worktrees/feature'), impostor: await resolve('/work/impostor'), impostorTrusted, trusted }, { + assert.deepStrictEqual({ linked: await resolve('/work/vscode.worktrees/feature'), impostor: await resolve('/work/impostor'), impostorMove, impostorTrusted, trusted }, { linked: '/work/vscode.worktrees/feature', impostor: 'none', + impostorMove: 'refused', impostorTrusted: [], trusted: ['/work/vscode.worktrees/feature'], }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatWidgetService.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatWidgetService.test.ts index e31ea418567b98..24c73a75a3f0d4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatWidgetService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatWidgetService.test.ts @@ -218,7 +218,7 @@ suite('ChatWidgetService', () => { const oldWidget = createWidget(session); const emptyOldWidget = createWidget(undefined); const hostWidget = upcastPartial({}); - const view = { widget: oldWidget, focus: () => calls.push('view.focus'), loadSession: async () => { calls.push('view.loadSession'); }, reportShown: (_resource: URI, reason: string) => calls.push(`view.reportShown ${reason}`) }; + const view = { widget: oldWidget, shownSession: constObservable(undefined), focus: () => calls.push('view.focus'), loadSession: async () => { calls.push('view.loadSession'); }, reportShown: (_resource: URI, reason: string) => calls.push(`view.reportShown ${reason}`) }; instantiationService.stub(IEditorGroupsService, { groups: [] }); instantiationService.stub(IViewsService, { getViewWithId: (() => view) as unknown as IViewsService['getViewWithId'], diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index b3c9889571b12f..71e6a0c5397c3d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -191,6 +191,7 @@ suite('ChatViewPane session transitions', () => { const widget = { get viewModel() { return shown && { model: shown, sessionResource: shown.sessionResource }; }, getViewState: () => ({}), + restoreViewState: () => { }, getInput: () => input, setInput: (value: string) => { input = value; }, setModel: (model: IChatModel | undefined) => { @@ -361,6 +362,8 @@ suite('ChatViewPane session transitions', () => { test('through the Chat view host, each open reports the chat it shows exactly once: a chat it loads, the chat it shows already when picked again, to restore, and a committed one', async () => { const rig = createPane(); + // Each open below loads a chat of its own + rig.nextRef.dispose(); const savedResource = nextResource.with({ path: '/saved' }); const committedResource = nextResource.with({ path: '/committed' }); const reference = (resource: URI) => Object.assign(toDisposable(() => { }), { object: upcastPartial({ sessionResource: resource }) }); From 5201f7459b7baf6680d0e90330f95d0fff77123d Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:20:35 +0100 Subject: [PATCH 074/165] Worktree support: dispose the Chat view host service in the shown-session stream test, as #348309 makes it disposable Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index 80cbc222c0e603..976c2756a5020e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -458,7 +458,7 @@ suite('ChatViewPane session transitions', () => { instantiationService.stub(IViewDescriptorService, upcastPartial({})); instantiationService.stub(IWorkbenchLayoutService, upcastPartial({})); instantiationService.stub(IProgressService, upcastPartial({})); - const host = instantiationService.createInstance(ChatViewHostService); + const host = store.add(instantiationService.createInstance(ChatViewHostService)); const stream: string[] = []; store.add(autorun(reader => { const shown = rig.pane.shownSession.read(reader); From 2c60b3d77b3dbcea25c9a432a431c92caee67acd Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:25:24 +0100 Subject: [PATCH 075/165] chat: dispose the Chat view host service of the shown-chat test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index ac0e46bddf6cdc..beca95767ffa6e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -515,7 +515,7 @@ suite('ChatViewPane session transitions', () => { instantiationService.stub(IViewDescriptorService, upcastPartial({})); instantiationService.stub(IWorkbenchLayoutService, upcastPartial({})); instantiationService.stub(IProgressService, upcastPartial({})); - const host = instantiationService.createInstance(ChatViewHostService); + const host = store.add(instantiationService.createInstance(ChatViewHostService)); const stream: string[] = []; store.add(autorun(reader => { const shown = rig.pane.shownSession.read(reader); From 8ef0cbc142ca9d8927036006ab0405d48f180ed6 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:26:09 +0100 Subject: [PATCH 076/165] Chat view pane test: dispose the Chat view host service that the test creates With #348302, the service is a tracked disposable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index 153887ee26922e..dc0882bdb85f48 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -515,7 +515,7 @@ suite('ChatViewPane session transitions', () => { instantiationService.stub(IViewDescriptorService, upcastPartial({})); instantiationService.stub(IWorkbenchLayoutService, upcastPartial({})); instantiationService.stub(IProgressService, upcastPartial({})); - const host = instantiationService.createInstance(ChatViewHostService); + const host = store.add(instantiationService.createInstance(ChatViewHostService)); const stream: string[] = []; store.add(autorun(reader => { const shown = rig.pane.shownSession.read(reader); From 8c6f87965190814f845c854e6a92a373b073f1d5 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:27:22 +0100 Subject: [PATCH 077/165] chat: New Chat of the Chat view with options: own the chat, preserve focus From #348309 (1ee85e81d29, 9ce6adce9cb), where it was made against this seam: - IChatViewHostNewChatOptions { preserveFocus, ownChat }; IChatViewHost(Service).newChat(target?, options?). - ChatViewPane.newChat with ownChat runs its own scoped clear, supersedes a load or restore, aims the composer and honours preserveFocus; the service falls through to the Chat view when a registered host declines ownChat. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/chatViewHost.ts | 15 ++++++- .../viewPane/chatViewHostService.ts | 21 ++++++--- .../widgetHosts/viewPane/chatViewPane.ts | 26 +++++++---- .../viewPane/chatViewHostService.test.ts | 13 +++++- .../widgetHosts/viewPane/chatViewPane.test.ts | 44 ++++++++++++++++--- 5 files changed, 95 insertions(+), 24 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts index 2841dfecac1b16..803b409e773f1e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts +++ b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts @@ -62,6 +62,16 @@ export interface IChatViewNewChatTarget { readonly sessionType?: string; } +/** How {@link IChatViewHost.newChat} starts a new chat. */ +export interface IChatViewHostNewChatOptions { + readonly preserveFocus?: boolean; + /** + * Starts the new chat in the host's own view, also where New Chat would leave it to the chat widget it acts on, such as the + * Chat view that shows a chat: a new session that a sessions list starts, rather than New Chat of a chat widget. + */ + readonly ownChat?: boolean; +} + /** How {@link IChatViewHost.startSession} starts a session that its caller may send a first request to. */ export interface IChatViewHostSessionStartOptions { /** Whether the session is a new local chat, rather than the session of the given resource. */ @@ -116,7 +126,7 @@ export interface IChatViewHost { * it takes it once its new chat shows, so that a request that follows goes to it. A host that does not implement it leaves * New Chat to the chat widget it acts on. */ - newChat?(target?: IChatViewNewChatTarget): Promise; + newChat?(target?: IChatViewNewChatTarget, options?: IChatViewHostNewChatOptions): Promise; /** The chat the host shows; a host that does not implement it reports none. */ readonly shownSession?: IObservable; } @@ -130,7 +140,8 @@ export interface IChatViewHostService extends IChatViewHost { revealIfShowing(sessionResource: URI, options?: IChatViewHostOpenOptions): Promise; - newChat(target?: IChatViewNewChatTarget): Promise; + /** A new chat of its own (`ownChat`) that the host in use leaves to the Chat view starts in the Chat view. */ + newChat(target?: IChatViewNewChatTarget, options?: IChatViewHostNewChatOptions): Promise; /** Whether a registered host replaces the Chat view, which then shows only the chats the host leaves to it. */ readonly replaced: IObservable; diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts index b6febb7485bab9..efee9159df0bec 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts @@ -14,7 +14,7 @@ import { IViewDescriptorService, ViewContainerLocation } from '../../../../../co import { IWorkbenchLayoutService, Parts } from '../../../../../services/layout/browser/layoutService.js'; import { IViewsService } from '../../../../../services/views/common/viewsService.js'; import { ChatViewId, IChatWidget } from '../../chat.js'; -import { getChatViewHostOpenReason, IChatViewHost, IChatViewHostOpenOptions, IChatViewHostRevealOptions, IChatViewHostService, IChatViewHostSessionStart, IChatViewHostSessionStartOptions, IChatViewHostShownSession, IChatViewNewChatTarget } from '../../chatViewHost.js'; +import { getChatViewHostOpenReason, IChatViewHost, IChatViewHostOpenOptions, IChatViewHostRevealOptions, IChatViewHostService, IChatViewHostSessionStart, IChatViewHostSessionStartOptions, IChatViewHostNewChatOptions, IChatViewHostShownSession, IChatViewNewChatTarget } from '../../chatViewHost.js'; import { ChatViewPane } from './chatViewPane.js'; /** The Chat view, as the host of its chats. */ @@ -74,8 +74,13 @@ export class ChatViewPaneHost implements IChatViewHost { return chatView?.widget; } - async newChat(target?: IChatViewNewChatTarget): Promise { - return this.viewsService.isViewVisible(ChatViewId) && !!this.viewsService.getViewWithId(ChatViewId)?.newChat(target); + async newChat(target?: IChatViewNewChatTarget, options?: IChatViewHostNewChatOptions): Promise { + if (options?.ownChat) { + const view = await this.viewsService.openView(ChatViewId, !options.preserveFocus); + return !!view && await view.newChat(target, options); + } + const view = this.viewsService.isViewVisible(ChatViewId) ? this.viewsService.getViewWithId(ChatViewId) : undefined; + return !!view && await view.newChat(target, options); } async toggle(): Promise { @@ -278,9 +283,13 @@ export class ChatViewHostService extends Disposable implements IChatViewHostServ return host.startSession(sessionResource, options); } - async newChat(target?: IChatViewNewChatTarget): Promise { - const host = this.host; - const taken = await host.newChat?.(target) ?? false; + async newChat(target?: IChatViewNewChatTarget, options?: IChatViewHostNewChatOptions): Promise { + let host = this.host; + let taken = await host.newChat?.(target, options) ?? false; + if (!taken && options?.ownChat && host !== this.chatViewHost) { + host = this.chatViewHost; + taken = await host.newChat?.(target, options) ?? false; + } if (taken) { this.claim(host, true); } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index bf47f77ecfa17c..31bd3e85727ffe 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -72,7 +72,7 @@ import { AgentSessionsViewerOrientation, AgentSessionsViewerPosition } from '../ import { IChatViewSessionsComposer, IChatViewSessionsList, IChatViewSessionsListControl, IChatViewSessionsListService } from './chatViewSessionsList.js'; import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; import { CHAT_WIDGET_VIEW_STATE_CACHE_LIMIT, ChatViewId, IChatWidgetService, IChatWidgetViewModelChangeEvent, IChatWidgetViewState, setModelPreservingInputTypedWhileLoading } from '../../chat.js'; -import { IChatViewHostShownSession, IChatViewNewChatTarget } from '../../chatViewHost.js'; +import { IChatViewHostNewChatOptions, IChatViewHostShownSession, IChatViewNewChatTarget } from '../../chatViewHost.js'; import { IActivityService, ProgressBadge } from '../../../../../services/activity/common/activity.js'; import { DeferredPromise, disposableTimeout, raceCancellation, RunOnceScheduler } from '../../../../../../base/common/async.js'; import { AgentSessionsFilter, AgentSessionsGrouping } from '../../agentSessions/agentSessionsFilter.js'; @@ -910,21 +910,31 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { /** * New Chat of the Chat view: the provided composer starts over, aimed at `target` when given, and takes New Chat while it * shows in place of the chat. Returns whether it took it; otherwise New Chat clears the chat, in whose place the composer - * then shows. + * then shows. With `ownChat`, it always takes it: it starts a new chat in place of the one it shows, also one still loading. */ - newChat(target?: IChatViewNewChatTarget): boolean { - if (!this.providedComposer) { + async newChat(target?: IChatViewNewChatTarget, options?: IChatViewHostNewChatOptions): Promise { + if (options?.ownChat) { + if (!this.composerShown) { + // Its own new chat, rather than the chat widget that New Chat acts on, such as one that has focus elsewhere + await this.clear(); + } + } else if (!this.providedComposer) { return false; - } - if (!this.composerShown) { + } else if (!this.composerShown) { this.providedComposer.newChat(target); return false; } // A chat still loading or restoring, which cleared the chat and so shows the composer, no longer replaces it this.loadSessionCts.value?.cancel(); this._applyModelCts.value?.cancel(); - this.providedComposer.newChat(target); - this.providedComposer.focus(); + this.providedComposer?.newChat(target); + if (!options?.preserveFocus) { + if (this.providedComposer) { + this.providedComposer.focus(); + } else { + this._widget?.focusInput(); + } + } return true; } diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts index 4e4b6139e44b6d..b36a2a8ba99396 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts @@ -18,7 +18,7 @@ import { IWorkbenchLayoutService, Parts } from '../../../../../../services/layou import { IViewsService } from '../../../../../../services/views/common/viewsService.js'; import { ChatViewId } from '../../../../browser/chat.js'; import { ChatWidget } from '../../../../browser/widget/chatWidget.js'; -import { IChatViewHost, IChatViewHostShownSession } from '../../../../browser/chatViewHost.js'; +import { IChatViewHost, IChatViewHostNewChatOptions, IChatViewHostShownSession, IChatViewNewChatTarget } from '../../../../browser/chatViewHost.js'; import { ChatViewHostService } from '../../../../browser/widgetHosts/viewPane/chatViewHostService.js'; import { ChatViewPane } from '../../../../browser/widgetHosts/viewPane/chatViewPane.js'; @@ -176,6 +176,17 @@ suite('ChatViewHostService', () => { assert.deepStrictEqual({ results, started }, { results: [false, true, false], started: 1 }); }); + test('a new chat of its own that the host in use leaves to the Chat view starts in the Chat view, which it shows', async () => { + const { service, view, calls } = createService(); + Object.assign(view, { newChat: async (target: IChatViewNewChatTarget | undefined, options: IChatViewHostNewChatOptions | undefined) => { calls.push(`view.newChat ${target?.folder?.path} ownChat=${options?.ownChat}`); return true; } }); + const registration = service.registerHost(upcastPartial({ newChat: async () => { calls.push('host.newChat'); return false; } })); + + const taken = await service.newChat({ folder: URI.file('/repo') }, { ownChat: true, preserveFocus: true }); + registration.dispose(); + + assert.deepStrictEqual({ taken, calls }, { taken: true, calls: ['host.newChat', `openView ${ChatViewId} focus=false`, 'view.newChat /repo ownChat=true'] }); + }); + test('reveal waits for the Chat view\'s widget, which its body creates as it first renders, only when asked to', async () => { const { service, view, created, accesses } = createService({ widgetCreatedLater: true }); const unchanged = await service.reveal(false); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index beca95767ffa6e..dc0882bdb85f48 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -26,7 +26,7 @@ import { TestContextService, TestStorageService } from '../../../../../../test/c import { ChatViewPane } from '../../../../browser/widgetHosts/viewPane/chatViewPane.js'; import { ChatViewSessionsListService, IChatViewSessionsListProvider } from '../../../../browser/widgetHosts/viewPane/chatViewSessionsList.js'; import { IChatWidgetViewModelChangeEvent } from '../../../../browser/chat.js'; -import { IChatViewHostShownSession } from '../../../../browser/chatViewHost.js'; +import { IChatViewHostNewChatOptions, IChatViewHostShownSession, IChatViewNewChatTarget } from '../../../../browser/chatViewHost.js'; import { ChatWidget } from '../../../../browser/widget/chatWidget.js'; import { mainWindow } from '../../../../../../../base/browser/window.js'; import { ChatOpenDiagnosticsService } from '../../../../common/chatOpenDiagnosticsService.js'; @@ -138,17 +138,17 @@ suite('ChatViewPane sessions refresh', () => { assert.deepStrictEqual({ states, withoutComposer: withoutComposer.composerShown }, { states: [true, false, true, false], withoutComposer: false }); }); - test('New Chat starts the provided composer over, and is the composer\'s while it shows in place of the chat, superseding a chat still loading or restoring', () => { + test('New Chat starts the provided composer over, and is the composer\'s while it shows in place of the chat, superseding a chat still loading or restoring', async () => { const calls: string[] = []; const composer = { newChat: () => calls.push('newChat'), focus: () => calls.push('focus') }; const cancellable = (name: string) => ({ value: { cancel: () => calls.push(`cancel ${name}`) } }); type Host = { providedComposer: typeof composer | undefined; composerShown: boolean; loadSessionCts: ReturnType; _applyModelCts: ReturnType }; - const newChat = Reflect.get(ChatViewPane.prototype, 'newChat') as (this: Host) => boolean; + const newChat = Reflect.get(ChatViewPane.prototype, 'newChat') as (this: Host) => Promise; const host = (providedComposer: typeof composer | undefined, composerShown: boolean): Host => ({ providedComposer, composerShown, loadSessionCts: cancellable('load'), _applyModelCts: cancellable('restore') }); const results = { - shown: newChat.call(host(composer, true)), - hidden: newChat.call(host(composer, false)), - withoutComposer: newChat.call(host(undefined, false)), + shown: await newChat.call(host(composer, true)), + hidden: await newChat.call(host(composer, false)), + withoutComposer: await newChat.call(host(undefined, false)), }; assert.deepStrictEqual({ results, calls }, { results: { shown: true, hidden: false, withoutComposer: false }, calls: ['cancel load', 'cancel restore', 'newChat', 'focus', 'newChat'] }); }); @@ -610,7 +610,7 @@ suite('ChatViewPane session transitions', () => { await timeout(150); // The cleared chat is a new chat: the composer shows in its place Object.assign(rig.pane, { composerShown: true }); - const taken = (Reflect.get(ChatViewPane.prototype, 'newChat') as (this: object) => boolean).call(rig.pane); + const taken = await (Reflect.get(ChatViewPane.prototype, 'newChat') as (this: object) => Promise).call(rig.pane); await acquiring.complete(rig.nextRef); await loading; assert.deepStrictEqual({ taken, composerCalls, bindings: rig.bindings, disposed: rig.disposed }, { @@ -621,6 +621,36 @@ suite('ChatViewPane session transitions', () => { }); })); + test('a new chat of its own, as a sessions list starts one, replaces the chat it shows and its slow load, aimed at the target, without focus', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const rig = createPane(); + const acquiring = new DeferredPromise(); + const freshResource = URI.from({ scheme: SessionType.AgentHostCopilot, path: '/untitled-fresh' }); + const freshRef = Object.assign(toDisposable(() => { }), { object: upcastPartial({ sessionResource: freshResource }) }); + const calls: string[] = []; + Object.assign(rig.pane, { + chatService: { acquireOrLoadSession: () => acquiring.p }, + acquireDefaultNewSession: async () => ({ modelRef: freshRef }), + updateViewState: () => { }, + providedComposer: { newChat: (target?: IChatViewNewChatTarget) => calls.push(`composer newChat ${target?.folder?.path}`), focus: () => calls.push('composer focus') }, + composerShown: false, + }); + // A load still under way, while the chat it replaces still shows + const loading = rig.pane.loadSession(nextResource); + await timeout(150); + + const taken = await (Reflect.get(ChatViewPane.prototype, 'newChat') as (this: object, target: IChatViewNewChatTarget, options: IChatViewHostNewChatOptions) => Promise) + .call(rig.pane, { folder: URI.file('/repo') }, { ownChat: true, preserveFocus: true }); + await acquiring.complete(rig.nextRef); + await loading; + + assert.deepStrictEqual({ taken, calls, bindings: rig.bindings, shown: rig.widget.viewModel?.sessionResource.path }, { + taken: true, + calls: ['composer newChat /repo'], + bindings: [freshResource], + shown: '/untitled-fresh', + }); + })); + for (const untitled of [true, false]) { test(`${untitled ? 'new chats stay bound' : 'saved chats still clear'} while a slow load is pending`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { const rig = createPane(); From e5ccc602e465e325b3a7eb51440e1bf450029ec9 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:28:25 +0100 Subject: [PATCH 078/165] chat: the composer takes focus only from the chat it replaces As the composer shows in place of a chat that had focus, such as New Chat clearing it, it takes focus; focus in the sessions or other controls of the Chat view stays, also as a held load clears the chat. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../widgetHosts/viewPane/chatViewPane.ts | 6 ++-- .../viewPane/chatViewHostService.test.ts | 1 - .../widgetHosts/viewPane/chatViewPane.test.ts | 28 +++++++++++-------- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 31bd3e85727ffe..c745812c3b05d0 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -893,15 +893,17 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { /** * Shows the provided composer in place of the chat while the chat is a new chat (empty, without a title), unless the - * Chat view shows only the sessions. As New Chat clears a chat with focus in the Chat view, focus moves to the composer. + * Chat view shows only the sessions. As the chat it replaces had focus, such as New Chat clearing it, focus moves to the + * composer; focus elsewhere, such as in the sessions, stays. */ private updateComposerVisibility(): void { const shown = !!this.providedComposer && !this.providedSessionsList?.sessionsOnly.get() && (!this._widget?.viewModel || (this._widget.isEmpty() && !this._widget.viewModel.model.title)); if (shown !== this.composerShown) { + const chatHadFocus = shown && !!this._widget?.domNode && isAncestorOfActiveElement(this._widget.domNode); this.composerShown = shown; this.viewPaneContainer?.classList.toggle('shows-composer', shown); this.relayout(); - if (shown && this.element && isAncestorOfActiveElement(this.element)) { + if (chatHadFocus) { this.providedComposer?.focus(); } } diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts index b36a2a8ba99396..3939fc99ea05aa 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts @@ -357,4 +357,3 @@ suite('ChatViewHostService', () => { assert.deepStrictEqual({ shown: service.shownSession.get()?.resource.path, replaced: service.replaced.get() }, { shown: '/chat-view', replaced: false }); }); }); - diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index dc0882bdb85f48..3e9f9210bc0391 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -169,10 +169,13 @@ suite('ChatViewPane sessions refresh', () => { assert.deepStrictEqual(focused, ['sessions', 'composer', 'chat']); }); - test('as New Chat clears a chat with focus in the Chat view, the composer that shows in its place takes focus', () => { + test('as the composer shows in place of a chat that had focus, such as New Chat clearing it, it takes focus, but not from the sessions or other controls of the Chat view', () => { const focused: string[] = []; const element = mainWindow.document.body.appendChild(document.createElement('div')); - const input = element.appendChild(document.createElement('textarea')); + const chat = element.appendChild(document.createElement('div')); + const chatInput = chat.appendChild(document.createElement('textarea')); + // Such as the filter of the sessions beside the chat + const sessionsFilter = element.appendChild(document.createElement('input')); let empty = false; const host = { providedComposer: { focus: () => focused.push('composer') }, @@ -180,26 +183,27 @@ suite('ChatViewPane sessions refresh', () => { composerShown: false, viewPaneContainer: document.createElement('div'), element, - _widget: { get viewModel() { return { model: { title: '' } }; }, isEmpty: () => empty }, + _widget: { domNode: chat, get viewModel() { return { model: { title: '' } }; }, isEmpty: () => empty }, relayout: () => { }, }; const update = Reflect.get(ChatViewPane.prototype, 'updateComposerVisibility') as (this: typeof host) => void; - try { - update.call(host); - input.focus(); - // New Chat clears the chat - empty = true; - update.call(host); - // Shown again, without focus in the Chat view + const steps: string[][] = []; + const clearWith = (focus: HTMLElement) => { empty = false; update.call(host); - input.blur(); + focus.focus(); + // The chat clears: New Chat, or a saved chat that loads while it is held, which clears the chat it replaces empty = true; update.call(host); + steps.push(focused.splice(0)); + }; + try { + clearWith(chatInput); + clearWith(sessionsFilter); } finally { element.remove(); } - assert.deepStrictEqual(focused, ['composer']); + assert.deepStrictEqual(steps, [['composer'], []]); }); test('the Chat view takes the sessions list of the provider registered last, and its own once none is', () => { From 3932f25ec53805e454a8983c41d97b4751094f76 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:29:00 +0100 Subject: [PATCH 079/165] Worktree support: load the editor window's worktree contribution from the workbench, out of the Agents window's mobile package Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/agentSessions/agentSessions.contribution.ts | 1 - src/vs/workbench/workbench.common.main.ts | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts index 9ad4c94300af97..9fd8154696b322 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts @@ -21,7 +21,6 @@ import { AgentHostPermissionUiContribution } from './agentHost/agentHostPermissi import { registerExternalSessionsFilterMenu } from './externalSessionsFilterMenu.js'; import './agentHost/agentHostChatInputPicker.contribution.js'; import './agentHost/agentHostModeSynchronizer.js'; -import './agentHost/agentHostWorktree.contribution.js'; import { ChatSessionArchiveActionWordingSettingId, getChatSessionArchiveActionWording } from '../../../../../platform/chat/common/sessionArchiveActions.js'; import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index 9a8886db1a7f0e..c79d6664fd5beb 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -243,6 +243,8 @@ import './contrib/mcp/browser/mcp.view.contribution.js'; import './contrib/chat/browser/chatSessions/chatSessions.contribution.js'; import './contrib/sessions/browser/editorWindow/editorWindowSessions.contribution.js'; import './contrib/sessions/providers/agentsView/browser/agentsViewProviders.contribution.js'; +// Worktree support of the editor window, not of the Agents window or its mobile package +import './contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.js'; import './contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.js'; import './contrib/chat/browser/contextContrib/chatContext.contribution.js'; import './contrib/imageCarousel/browser/imageCarousel.contribution.js'; From a5ecaab92fc260eaec3c707ab71a51680d53c26c Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:34:44 +0100 Subject: [PATCH 080/165] Editor cloud sandbox test: its workspace stub has a workbench state event, which the session list now follows Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../remoteAgentHost/editorCloudSandboxContribution.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/editorCloudSandboxContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/editorCloudSandboxContribution.test.ts index 9b9e3af37420c0..a131ec6352b53e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/editorCloudSandboxContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/editorCloudSandboxContribution.test.ts @@ -620,6 +620,7 @@ function createHarness(store: Pick, options?: { }()); instantiationService.stub(IWorkspaceContextService, new class extends mock() { override readonly onDidChangeWorkspaceFolders = workspaceFoldersChanged.event; + override readonly onDidChangeWorkbenchState = Event.None; override getWorkspace() { return testIWorkspace({ id: 'editor-workspace', folders: state.workspaceFolders }); } From 787f2466d9b19482b463e0d6171bde80ea84816f Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:39:49 +0100 Subject: [PATCH 081/165] Worktree infra: a Chat view load reports its chat itself, once, unless a later open supersedes it The host no longer infers from the shown session whether a load reported its chat: a superseded restore no longer reports its reason after the explicit open that superseded it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../viewPane/chatViewHostService.ts | 6 +-- .../widgetHosts/viewPane/chatViewPane.ts | 10 ++++ .../viewPane/chatViewHostService.test.ts | 6 +-- .../widgetHosts/viewPane/chatViewPane.test.ts | 47 +++++++++++++++++++ 4 files changed, 61 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts index 2e0d3fbb2c7827..77362511cde31a 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts @@ -61,12 +61,8 @@ export class ChatViewPaneHost implements IChatViewHost { const chatView = await this.viewsService.openView(ChatViewId, !options?.preserveFocus); if (chatView) { const reason = getChatViewHostOpenReason(options); - const shownBefore = chatView.shownSession.get(); + // Loading reports the chat once, also one it showed already, unless a later open supersedes it await chatView.loadSession(sessionResource, options?.sessionTypeSelectionReason, reason); - // A chat it showed already, which loading does not set again, is reported once: a chat that loading set is reported already - if (chatView.shownSession.get() === shownBefore) { - chatView.reportShown(sessionResource, reason); - } if (!options?.preserveFocus) { chatView.focusInput(); } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index bf22c302e18139..e2ea89be5b9bdd 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -173,6 +173,8 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { /** Why the view sets the chat it shows, while it sets it: it restores the chat it last showed, or the user brought one up. */ private _showReason: IChatViewHostShownSession['reason'] = 'open'; private readonly _shownSession = observableValue(this, undefined); + /** How often it reported the chat it shows ({@link shownSession}): whether a load reported its chat itself. */ + private _shownReports = 0; /** The chat the view shows, and whether it shows it as the user brought it up or as it restored it. */ readonly shownSession: IObservable = this._shownSession; private readonly customizationMigrationsAvailable = observableValue(this, false); @@ -1525,6 +1527,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { // Each time the user opens it, also where it is reported as opened already, as where it works may matter again if (shown && (reason === 'open' || shown.reason !== reason) && isEqual(shown.resource, sessionResource)) { this._shownSession.set({ resource: sessionResource, reason }, undefined); + this._shownReports++; } } @@ -1537,6 +1540,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private updateShownSession(reason: IChatViewHostShownSession['reason'] = this._showReason): void { const shownResource = this._widget.viewModel?.sessionResource; this._shownSession.set(shownResource && { resource: shownResource, reason }, undefined); + this._shownReports++; } /** @param showReason why it shows the chat ({@link shownSession}). */ @@ -1702,6 +1706,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { this.loadSessionCts.value?.cancel(); const cts = this.loadSessionCts.value = new CancellationTokenSource(); const token = cts.token; + const shownReports = this._shownReports; const open = this._openDiagnostics.value = !isUntitledChatSession(sessionResource) && this.layoutService.isRestored() ? this.chatOpenDiagnosticsService.startOpen('panel', sessionResource, token, alreadyShown) : undefined; @@ -1782,6 +1787,11 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } else if (!token.isCancellationRequested) { open?.fail(new Error('Another chat was shown instead of the requested one')); } + // The chat it shows is reported once for this load: a chat it set is reported already, such as one it showed + // already is not, and a load that a later one superseded reports nothing, so the latest open's reason stands + if (!token.isCancellationRequested && this._shownReports === shownReports) { + this.reportShown(sessionResource, showReason); + } return loadedModel; } catch (error) { open?.fail(error); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts index 69a52095d5bfa4..ae2c33f81522d2 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts @@ -88,9 +88,9 @@ suite('ChatViewHostService', () => { calls: [ `openView ${ChatViewId} focus=true`, `openView ${ChatViewId} focus=false`, - `openView ${ChatViewId} focus=true`, 'loadSession test:/session reason=explicitOverride', 'reportShown test:/session open', 'focusInput', - `openView ${ChatViewId} focus=false`, 'loadSession test:/session reason=undefined restore', 'reportShown test:/session restore', - `openView ${ChatViewId} focus=false`, 'loadSession test:/session reason=undefined commit', 'reportShown test:/session commit', + `openView ${ChatViewId} focus=true`, 'loadSession test:/session reason=explicitOverride', 'focusInput', + `openView ${ChatViewId} focus=false`, 'loadSession test:/session reason=undefined restore', + `openView ${ChatViewId} focus=false`, 'loadSession test:/session reason=undefined commit', `setPartHidden false ${Parts.AUXILIARYBAR_PART}`, `openView ${ChatViewId} focus=undefined`, 'beforeOpen', 'suppress', 'progress', 'loadSession test:/session reason=explicitOverride', 'focus', `openView ${ChatViewId} focus=undefined`, 'loadSession test:/moved reason=undefined', diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index 71e6a0c5397c3d..8f48e6743a13a9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -194,7 +194,11 @@ suite('ChatViewPane session transitions', () => { restoreViewState: () => { }, getInput: () => input, setInput: (value: string) => { input = value; }, + // Like ChatWidget: the chat it shows already is not set again, and its view model does not change setModel: (model: IChatModel | undefined) => { + if (model && shown && model.sessionResource.toString() === shown.sessionResource.toString()) { + return; + } shown = model; input = ''; bindings.push(model?.sessionResource); @@ -222,6 +226,7 @@ suite('ChatViewPane session transitions', () => { } = Object.assign(Object.create(ChatViewPane.prototype), { _store: paneStore, _showReason: 'open', + _shownReports: 0, _shownSession: shownSession, shownSession, modelRef, @@ -402,6 +407,46 @@ suite('ChatViewPane session transitions', () => { }); }); + test('through the Chat view host, an open of the chat it shows already that supersedes a restore of it under way reports the open, not the restore', async () => { + const rig = createPane(); + rig.nextRef.dispose(); + const chat = nextResource.with({ path: '/chat' }); + const reference = () => Object.assign(toDisposable(() => { }), { object: upcastPartial({ sessionResource: chat }) }); + // The restore's load and the explicit open's each wait, so that the superseded restore ends first + const restoring = new DeferredPromise(); + const opening = new DeferredPromise(); + const loads = [async () => reference(), async () => { await restoring.p; return reference(); }, async () => { await opening.p; return reference(); }]; + Object.assign(rig.pane, { chatService: { acquireOrLoadSession: () => loads.shift()!() } }); + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IViewsService, upcastPartial({ + openView: (async () => rig.pane) as IViewsService['openView'], + onDidChangeViewVisibility: Event.None, + getViewWithId: (() => rig.pane as T) as IViewsService['getViewWithId'], + })); + instantiationService.stub(IViewDescriptorService, upcastPartial({})); + instantiationService.stub(IWorkbenchLayoutService, upcastPartial({})); + instantiationService.stub(IProgressService, upcastPartial({})); + const host = instantiationService.createInstance(ChatViewHostService); + await host.openSession(chat, { preserveFocus: true }); + const stream: string[] = []; + store.add(autorun(reader => { + const shown = rig.pane.shownSession.read(reader); + stream.push(shown ? `${shown.resource.path} ${shown.reason}` : 'none'); + })); + stream.length = 0; + + const restore = host.openSession(chat, { preserveFocus: true, restore: true }); + await timeout(0); + const open = host.openSession(chat, { preserveFocus: true }); + await timeout(0); + await restoring.complete(); + await restore; + await opening.complete(); + await open; + + assert.deepStrictEqual({ stream, shown: rig.pane.shownSession.get()?.reason }, { stream: ['/chat open'], shown: 'open' }); + }); + test('cancelling a replacement releases its pending reference before provider activation completes', async () => { const resolving = new DeferredPromise(); const rig = createPane(resolving.p); @@ -580,6 +625,8 @@ suite('ChatViewPane chat open diagnostics', () => { notificationService: { error: () => { } }, progressService: { withProgress: (_: unknown, task: () => Promise) => task() }, loadSessionCts: { value: undefined, }, + _shownSession: observableValue('shownSession', undefined), + _shownReports: 0, _applyModelCts: { value: undefined }, element: { ownerDocument: { hidden: false } }, _widget: { viewModel: options.shown ? { sessionResource: options.shown } : undefined, visible: true, getInput: () => '' }, From 5ca890b1ae358f7a1c53b941fe445305174dd5ba Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:40:04 +0100 Subject: [PATCH 082/165] Chat part: a recovery composer aims at the provider's session type; New Session in Same Folder stays visible - A new session that did not run opens a composer of its provider's own session type, resolved from the type of its chats (such as copilot for agent-host-copilot chats), rather than the chat resource's scheme. - New Session in Same Folder shows, disabled, while several sessions are selected, as the other session actions do. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentsView/agentsChatPartActions.ts | 3 ++- .../browser/agentsView/agentsChatViewHost.ts | 13 +++++++++---- .../agentsView/agentsChatPartActions.test.ts | 2 +- .../browser/agentsView/agentsChatViewHost.test.ts | 13 ++++++++----- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartActions.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartActions.ts index 7cb853e2484577..87e150b80451c0 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartActions.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartActions.ts @@ -195,7 +195,8 @@ export function registerAgentsViewNewSessionActions(): IDisposable { title: localize2('agentsView.newSessionInSameFolder', "New Session in Same Folder"), // For one session: its folder precondition: ContextKeyExpr.and(ChatContextKeys.enabled, SessionItemIsMultiSelectionContext.negate()), - menu: [{ id: SessionItemContextMenuId, group: '0_new', order: 0, when: ContextKeyExpr.and(ChatContextKeys.enabled, SessionItemIsMultiSelectionContext.negate()) }], + // Shown, disabled, while several sessions are selected, as the other session actions are + menu: [{ id: SessionItemContextMenuId, group: '0_new', order: 0, when: ChatContextKeys.enabled }], }); } async run(accessor: ServicesAccessor, context?: ISession | ISession[]): Promise { diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts index 03bcfded0914a7..9b0c2a342a159a 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts @@ -172,7 +172,7 @@ export class AgentsChatViewHost implements IChatViewHost { model.dispose(); throw new CancellationError(); } - const sessionType = sessionResource.scheme; + const sessionType = getChatSessionType(sessionResource); let composer: Promise | undefined; return { moved: async () => { }, @@ -199,13 +199,18 @@ export class AgentsChatViewHost implements IChatViewHost { }; } - /** Shows a new-session composer of `sessionType` and resolves to the widget of its input, whose submits create the session. */ - private async _openComposer(sessionType: string): Promise { + /** + * Shows a new-session composer of the sessions of chat session type `chatSessionType` and resolves to the widget of its + * input, whose submits create the session. + */ + private async _openComposer(chatSessionType: string): Promise { if (!this._available) { return undefined; } + // The provider's own type, which may differ from the type of its chats, such as `claude` of `agent-host-claude` chats + const offered = this._sessionsManagementService.getAllProviderSessionTypes().find(({ sessionType }) => (sessionType.chatSessionType ?? sessionType.id) === chatSessionType); // A composer of its own: the one it shows may aim at sessions of another type - await this._chatPart.openComposer({ ...this._composerTarget(), sessionTypeId: sessionType }, { newComposer: true }); + await this._chatPart.openComposer({ ...this._composerTarget(), providerId: offered?.providerId, sessionTypeId: offered?.sessionType.id }, { newComposer: true }); return this._chatPart.getComposerWidget(); } diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPartActions.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPartActions.test.ts index 44d176b2f126a2..f1a9907236124e 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPartActions.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPartActions.test.ts @@ -76,7 +76,7 @@ suite('Agents chat part actions', () => { menus: { sectionToolbar: 'chatIsEnabled && sessionSection.type == \'worktree\'', sectionContext: 'chatIsEnabled && sessionSection.type == \'worktree\'', - session: 'chatIsEnabled && !sessionItem.isMultiSelection', + session: 'chatIsEnabled', }, }); }); diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts index 87bf60a9de0c4d..485d881cc6186c 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts @@ -48,7 +48,7 @@ suite('AgentsChatViewHost', () => { const chatPart = upcastPartial({ activeChat: constObservable(undefined), show: async (_target, showOptions) => { calls.push(`show preserveFocus=${showOptions?.preserveFocus}`); }, - openComposer: async (target, openOptions) => { calls.push(`openComposer sessionType=${target?.sessionTypeId} newComposer=${openOptions?.newComposer}`); }, + openComposer: async (target, openOptions) => { calls.push(`openComposer ${target?.providerId ? `provider=${target.providerId} ` : ''}sessionType=${target?.sessionTypeId} newComposer=${openOptions?.newComposer}`); }, getComposerWidget: async () => composerWidget, openChat: async session => { calls.push(`openChat ${session.resource.toString()}`); }, isShowing: () => false, @@ -57,7 +57,7 @@ suite('AgentsChatViewHost', () => { const host = new AgentsChatViewHost( constObservable(options.ownsNewChat ?? true), chatPart, - upcastPartial({ getSessionForChatResource: () => undefined, getSession: () => undefined, getAllSessionTypes: () => [], getSessions: () => [], getAllSessions: () => [], onDidChangeSession: () => toDisposable(() => { }), ...options.sessionsManagementService }), + upcastPartial({ getSessionForChatResource: () => undefined, getSession: () => undefined, getAllSessionTypes: () => [], getAllProviderSessionTypes: () => [], getSessions: () => [], getAllSessions: () => [], onDidChangeSession: () => toDisposable(() => { }), ...options.sessionsManagementService }), upcastPartial({ getAllWidgets: () => options.widgets ?? [] }), upcastPartial({ isVisible: () => false }), upcastPartial({ getWorkspace: () => upcastPartial>({ folders: [] }) }), @@ -159,9 +159,12 @@ suite('AgentsChatViewHost', () => { }); }); - test('a new session that did not run is not shown: a composer of its type shows instead, which gets the request back', async () => { + test('a new session that did not run is not shown: a composer of its provider\'s type shows instead, which gets the request back', async () => { const untitled = URI.parse('agent-host-copilot:/untitled-1'); - const { host, calls, composerWidget } = createHost(); + const { host, calls, composerWidget } = createHost({ + // The provider's type of these chats differs from their chat session type, as an agent host's does + sessionsManagementService: { getAllProviderSessionTypes: () => [{ providerId: 'local-agent-host', sessionType: upcastPartial({ id: 'claude', chatSessionType: 'agent-host-claude' }) }, { providerId: 'local-agent-host', sessionType: upcastPartial({ id: 'copilot', chatSessionType: 'agent-host-copilot' }) }] }, + }); const start = await host.startSession(untitled, { local: false, importingConversation: false }); const shown = await start.complete(untitled, false); @@ -171,7 +174,7 @@ suite('AgentsChatViewHost', () => { assert.deepStrictEqual({ shown, composer: composer === composerWidget, calls }, { shown: false, composer: true, - calls: ['hold agent-host-copilot:/untitled-1', 'openComposer sessionType=agent-host-copilot newComposer=true', 'release agent-host-copilot:/untitled-1'], + calls: ['hold agent-host-copilot:/untitled-1', 'openComposer provider=local-agent-host sessionType=copilot newComposer=true', 'release agent-host-copilot:/untitled-1'], }); }); From f49a710a7cc442ba98db6dd34627b467d2139747 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:40:45 +0100 Subject: [PATCH 083/165] Worktree support: dispose the Chat view host service in the restore-then-open test, as #348309 makes it disposable Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index ff0c098198e350..e9a05e3094345b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -564,7 +564,7 @@ suite('ChatViewPane session transitions', () => { instantiationService.stub(IViewDescriptorService, upcastPartial({})); instantiationService.stub(IWorkbenchLayoutService, upcastPartial({})); instantiationService.stub(IProgressService, upcastPartial({})); - const host = instantiationService.createInstance(ChatViewHostService); + const host = store.add(instantiationService.createInstance(ChatViewHostService)); await host.openSession(chat, { preserveFocus: true }); const stream: string[] = []; store.add(autorun(reader => { From 7739d41d8ce140d9c2b3b9d7b6ea0aece37a2e44 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:46:09 +0100 Subject: [PATCH 084/165] chat: dispose the Chat view host service of the restore-supersede test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index 2e465533fc1501..0d38b1c68735a6 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -568,7 +568,7 @@ suite('ChatViewPane session transitions', () => { instantiationService.stub(IViewDescriptorService, upcastPartial({})); instantiationService.stub(IWorkbenchLayoutService, upcastPartial({})); instantiationService.stub(IProgressService, upcastPartial({})); - const host = instantiationService.createInstance(ChatViewHostService); + const host = store.add(instantiationService.createInstance(ChatViewHostService)); await host.openSession(chat, { preserveFocus: true }); const stream: string[] = []; store.add(autorun(reader => { From b84f234a7fdedd501775f551f58538dc359cf8c6 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:47:29 +0100 Subject: [PATCH 085/165] Sessions: hide the phone Mode and Model picker with AI features off; test reopening a chat of an unlisted session The combined picker of the new-session composer now requires chat to be enabled. A regression test covers reopening a closed chat of a session its provider still knows but no longer lists. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/sessionsManagementService.test.ts | 20 ++++++++++-- .../mobile/mobileChatInputConfigPicker.ts | 2 +- .../mobileChatInputConfigPicker.test.ts | 31 +++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 src/vs/workbench/contrib/sessions/providers/agentHost/test/browser/mobileChatInputConfigPicker.test.ts diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index 7c55f7514d57bc..e54a30d7f732f9 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -10348,10 +10348,12 @@ suite('SessionsManagementService', () => { }); } - function setup(sessions: ISession[]) { + function setup(sessions: ISession[], listed: (session: ISession) => boolean = () => true) { const provider = new class extends TestSessionsProvider { constructor() { super(sessions[0]); } - override getSessions(): ISession[] { return sessions; } + // Its listing scope may hide a session it still knows, such as one of another folder in an editor window + override getSessions(): ISession[] { return sessions.filter(listed); } + override getAllSessions(): ISession[] { return sessions; } }; const { part, content } = createSessionsPartTestHarness(disposables); const partService = new TestSessionsPartService(content.onDidFocusPane, content); @@ -10392,6 +10394,20 @@ suite('SessionsManagementService', () => { }); }); + test('reopens a closed chat of a session that its provider no longer lists, but still knows', async () => { + const sessionA = multiChatSession('A', [chat('mainA'), chat('b')]); + let inScope = true; + const { view } = setup([sessionA], () => inScope); + + await view.openSession(sessionA.resource); + const chatB = sessionA.chats.get().find(c => c.title.get() === 'b')!; + await view.closeChat(activeSessionOf(view)!, chatB); + inScope = false; + await view.reopenLastClosedItem(); + + assert.deepStrictEqual(view.activePane.get()!.tabs.openChats.get().map(c => c.title.get()), ['mainA', 'b']); + }); + test('an explicitly closed session returns to its grid index', async () => { const sessionA = multiChatSession('A', [chat('mainA')]); const sessionB = multiChatSession('B', [chat('mainB')]); diff --git a/src/vs/workbench/contrib/sessions/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts b/src/vs/workbench/contrib/sessions/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts index 99fe2a88fb66b5..832d35f141e3ff 100644 --- a/src/vs/workbench/contrib/sessions/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts +++ b/src/vs/workbench/contrib/sessions/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts @@ -372,7 +372,7 @@ export class MobileChatInputConfigPickerAction extends Action2 { id: Menus.NewSessionChatInputToolbar, group: 'navigation', order: 0, - when: ContextKeyExpr.and(SessionUsesCombinedConfigPickerContext, IsPhoneLayoutContext), + when: ContextKeyExpr.and(ChatContextKeys.enabled, SessionUsesCombinedConfigPickerContext, IsPhoneLayoutContext), }], }); } diff --git a/src/vs/workbench/contrib/sessions/providers/agentHost/test/browser/mobileChatInputConfigPicker.test.ts b/src/vs/workbench/contrib/sessions/providers/agentHost/test/browser/mobileChatInputConfigPicker.test.ts new file mode 100644 index 00000000000000..01724f12df99ec --- /dev/null +++ b/src/vs/workbench/contrib/sessions/providers/agentHost/test/browser/mobileChatInputConfigPicker.test.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { IAction2Options } from '../../../../../../../platform/actions/common/actions.js'; +import { Context } from '../../../../../../../platform/contextkey/browser/contextKeyService.js'; +import { ChatContextKeys } from '../../../../../chat/common/actions/chatContextKeys.js'; +import { IsPhoneLayoutContext, SessionUsesCombinedConfigPickerContext } from '../../../../common/contextkeys.js'; +import { MobileChatInputConfigPickerAction } from '../../browser/mobile/mobileChatInputConfigPicker.js'; + +suite('MobileChatInputConfigPickerAction', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('shows the combined Mode and Model picker on a phone only while AI features are enabled', () => { + const menus = new MobileChatInputConfigPickerAction().desc.menu as Exclude; + const shown = (enabled: boolean) => { + const context = new Context(1, null); + context.setValue(ChatContextKeys.enabled.key, enabled); + context.setValue(ChatContextKeys.inAutomationsDialog.key, true); + context.setValue(SessionUsesCombinedConfigPickerContext.key, true); + context.setValue(IsPhoneLayoutContext.key, true); + return menus.map(menu => !!menu.when?.evaluate(context)); + }; + + assert.deepStrictEqual({ enabled: shown(true), disabled: shown(false) }, { enabled: [true, true], disabled: [false, false] }); + }); +}); From 7156747395f7250c13fe2d1690efedf9a7dbf820 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:47:32 +0100 Subject: [PATCH 086/165] Session working set: the Changes pill follows what the working set shows An input that shows only the working set shows the Changes pill again once its working set shows no files, also files the input kept itself for a host without Accept Changes, such as one with only Session Changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostSessionInputPills.ts | 4 +-- .../browser/widget/input/chatInputPart.ts | 8 +++++ .../agentHostSessionInputPills.test.ts | 29 +++++++++++++++++-- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts index 238fe4acf0b1de..fd22a3607356ad 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts @@ -827,9 +827,9 @@ export class AgentHostSessionInputPills extends Disposable { }); // The Changes pill hides while the working set shows the changes, and while a new session's catalogue is pending. An // input that shows only the working set, whose Changes pill is the session's, shows the pill again once the working set - // shows no files, such as after Keep, so the session's other changes stay reachable. + // shows no files, such as after Keep, also files the input kept itself, so the session's other changes stay reachable. const changesPillHidden = this.changesPillHidden = derived(this, reader => workingSetPending.read(reader) - || (workingSetEnabled.read(reader) && (!this.workingSetOnly || (workingSetChanges.read(reader)?.length ?? 0) > 0))); + || (workingSetEnabled.read(reader) && (!this.workingSetOnly || this._widget.inputPart.sessionWorkingSetDiffs.read(reader).length > 0))); // Undo reverts a file's changes with the host's file-level Discard Changes operation, when the host offers it. const workingSetDiscard = derived(this, reader => { const kind = changesetTarget.read(reader)?.changeset.changeKind; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 418ad1d4e5613c..cbe7a2921f467a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -5526,6 +5526,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }); } + /** + * The files the session working set shows: the registered session file changes, without those the input kept itself, + * such as for a host without Accept Changes. None while the working set doesn't show. + */ + get sessionWorkingSetDiffs(): IObservable { + return this._sessionWorkingSetDiffs; + } + get hasSessionWorkingSet(): boolean { return !!this._sessionWorkingSetView.value; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts index 03eab813dc927f..a43db0172c277e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts @@ -10,7 +10,7 @@ import { VSBuffer } from '../../../../../../base/common/buffer.js'; import { IAction, SubmenuAction } from '../../../../../../base/common/actions.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { combinedDisposable, Disposable, ImmortalReference, toDisposable, type IReference } from '../../../../../../base/common/lifecycle.js'; -import { autorun, constObservable, IObservable, ISettableObservable, observableValue, transaction } from '../../../../../../base/common/observable.js'; +import { autorun, constObservable, derived, IObservable, ISettableObservable, observableValue, transaction } from '../../../../../../base/common/observable.js'; import { basename, dirname, isEqual } from '../../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -372,6 +372,10 @@ suite('AgentHostSessionInputPills', () => { let workingSetUndo: IChatSessionWorkingSetUndo | undefined; let workingSetKeep: IChatSessionWorkingSetKeep | undefined; let workingSetRegistrations = 0; + // What the input's working set shows: the registered changes without the files the input kept itself + const registeredChanges = observableValue | undefined>('registeredChanges', undefined); + const keptInInput = observableValue>('keptInInput', new Set()); + const sessionWorkingSetDiffs = derived(reader => (registeredChanges.read(reader)?.read(reader) ?? []).filter(diff => !keptInInput.read(reader).has(diff.modifiedURI.toString()))); let backgroundActivityPillSections: IChatBackgroundActivityPillSections | undefined; const widget = upcastPartial({ domNode: persistentContent, @@ -379,9 +383,12 @@ suite('AgentHostSessionInputPills', () => { persistentContentContainerElement: persistentContent, registerChatPetHorizontalPlatformProvider: () => Disposable.None, supportsSessionWorkingSet: options?.supportsSessionWorkingSet ?? true, + sessionWorkingSetDiffs, registerSessionFileChanges: (changes, undo, keep) => { workingSetRegistrations++; + registeredChanges.set(changes, undefined); return combinedDisposable( + toDisposable(() => registeredChanges.set(undefined, undefined)), autorun(reader => { workingSetChanges = changes.read(reader); workingSetUndo = undo?.read(reader); @@ -491,6 +498,8 @@ suite('AgentHostSessionInputPills', () => { workingSetUndo: () => workingSetUndo, workingSetKeep: () => workingSetKeep, workingSetRegistrations: () => workingSetRegistrations, + /** Keeps files in the input itself, as its working set does for a host without Accept Changes. */ + keepInInput: (diffs: readonly IEditSessionEntryDiff[]) => keptInInput.set(new Set([...keptInInput.get(), ...diffs.map(diff => diff.modifiedURI.toString())]), undefined), backgroundActivityPillSections: () => backgroundActivityPillSections, setWorkingSetEnabled: async (enabled: boolean) => { const configurationService = instantiationService.get(IConfigurationService) as TestConfigurationService; @@ -3145,7 +3154,7 @@ suite('AgentHostSessionInputPills', () => { }); }); - function createSessionWorkingSetPills(options?: { connectionAuthority?: string; resourceUris?: IAgentHostResourceUriMapper; workingSetOnly?: boolean }) { + function createSessionWorkingSetPills(options?: { connectionAuthority?: string; resourceUris?: IAgentHostResourceUriMapper; workingSetOnly?: boolean; withoutWorkingSet?: boolean }) { const errors: string[] = []; const confirmations: Pick[] = []; let confirmed = false; @@ -3155,7 +3164,7 @@ suite('AgentHostSessionInputPills', () => { chats: [], changesets: [ { label: 'Session Changes', uriTemplate: 'vendor-diff:/opaque/session-changes', changeKind: ChangesetKind.Session }, - { label: 'Working Set', uriTemplate: 'vendor-diff:/opaque/working-set', changeKind: ChangesetKind.WorkingSet }, + ...(options?.withoutWorkingSet ? [] : [{ label: 'Working Set', uriTemplate: 'vendor-diff:/opaque/working-set', changeKind: ChangesetKind.WorkingSet }]), ], }); const harness = createActivityPills(session, upcastPartial({ @@ -3347,6 +3356,20 @@ suite('AgentHostSessionInputPills', () => { }); }); + test('an input that shows only the working set of a host without a Working Set shows the Changes pill again once it kept every file itself', async () => { + const { harness, setOperations } = createSessionWorkingSetPills({ workingSetOnly: true, withoutWorkingSet: true }); + setOperations([discardWorkingSetChanges(ChangesetOperationStatus.Idle)]); + await harness.setWorkingSetEnabled(true); + const withFiles = { keptByHost: !!harness.workingSetKeep(), hidden: harness.pills.changesPillHidden.get() }; + // Session Changes has no Accept Changes: Keep hides the files in the input, which still lists them as changes + harness.keepInInput(harness.workingSetChanges() ?? []); + + assert.deepStrictEqual({ withFiles, afterKeep: { changes: harness.workingSetChanges()?.length, hidden: harness.pills.changesPillHidden.get() } }, { + withFiles: { keptByHost: false, hidden: true }, + afterKeep: { changes: 1, hidden: false }, + }); + }); + test('opens, keeps and undoes Working Set files through the ambient host of a remote window', async () => { // The ambient host of a remote window resolves as `local` but runs on the remote, so its files aren't on this machine const resourceUris = createEditorRemoteAgentHostResourceUriMapper('wsl+debian', agentHostAuthority('vscode-remote://wsl+debian')); From 685b00d63c03a5093dfb9270dbbc645cb1ed45e7 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:47:36 +0100 Subject: [PATCH 087/165] Editor window sessions: a later open supersedes one that still resolves An open that still resolves its session, or waits for it, no longer shows once another open started, so the user's latest pick shows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentsView/editorWindowSessionsService.ts | 9 +++- .../editorWindowSessionsRuntime.test.ts | 41 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsService.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsService.ts index d15997bf29f1cb..557ba000de3d26 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsService.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsService.ts @@ -121,18 +121,23 @@ export class EditorWindowSessionsService extends Disposable implements ISessions //#region Sessions and chats: shown by the presenter + /** Increases with each {@link openSession}, which shows its session only while it is the latest. */ + private _openGeneration = 0; + showSession(sessionResource: URI, options?: { preserveFocus?: boolean }): void { void this.openSession(sessionResource, options); } async openSession(sessionResource: URI, options?: IOpenSessionOptions): Promise { + // A later open supersedes this one while it resolves or waits for its session: the user's latest pick shows + const open = ++this._openGeneration; // Its session is known only while the providers are held: until it shows, such as in the chat part, which holds them then const hold = this._providers.acquire(); try { // Providers redirect superseded resources, such as of a session they adopted, and leave unfamiliar ones unchanged const resolved = await this._sessionsManagementService.resolveSessionResource(sessionResource, 'open'); - const session = await waitForAgentsViewSession(this._sessionsManagementService, () => this._sessionsManagementService.getSession(resolved)); - if (session) { + const session = open === this._openGeneration ? await waitForAgentsViewSession(this._sessionsManagementService, () => this._sessionsManagementService.getSession(resolved)) : undefined; + if (session && open === this._openGeneration) { await this._openSession(session, options); } } finally { diff --git a/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts b/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts index fe91e56907c4ea..01ece4a1bcb5b9 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts @@ -24,6 +24,10 @@ import { IEditorService } from '../../../../../services/editor/common/editorServ import { ISessionHost } from '../../../../../services/sessions/browser/sessionHost.js'; import { EditorWindowSessionsService } from '../../../browser/agentsView/editorWindowSessionsService.js'; import { ISessionsManagementService } from '../../../common/sessionsManagement.js'; +import { DeferredPromise } from '../../../../../../base/common/async.js'; +import { isEqual } from '../../../../../../base/common/resources.js'; +import { ISessionOpenTelemetryService } from '../../../browser/sessionOpenTelemetryService.js'; +import { buildTestSession } from '../../common/testSessionBuilder.js'; suite('EditorWindowSessionsRuntime', () => { @@ -198,4 +202,41 @@ suite('EditorWindowSessionsService', () => { withPresenter: ['composer /repo model=model devContainer=true toSide=true'], }); }); + + test('a later open supersedes an earlier one that still resolves its session, so the user\'s latest pick shows', async () => { + const opened: string[] = []; + const resolvingA = new DeferredPromise(); + const a = buildTestSession({ id: 'a', title: 'A' }).session; + const b = buildTestSession({ id: 'b', title: 'B' }).session; + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IChatViewHostService, upcastPartial({})); + instantiationService.stub(ICommandService, upcastPartial({})); + const presenterService = disposables.add(instantiationService.createInstance(EditorWindowSessionsPresenterService)); + const registration = presenterService.registerPresenter(upcastPartial({ + openChat: async session => { opened.push(session.sessionId); }, + isShowing: () => false, + })); + instantiationService.stub(IEditorWindowSessionsPresenterService, presenterService); + instantiationService.stub(ISessionHost, upcastPartial({})); + instantiationService.stub(ISessionsManagementService, upcastPartial({ + resolveSessionResource: async resource => isEqual(resource, a.resource) ? resolvingA.p : resource, + getSession: resource => [a, b].find(session => isEqual(session.resource, resource)), + })); + instantiationService.stub(IAgentsViewProvidersService, upcastPartial({ acquire: () => toDisposable(() => { }) })); + instantiationService.stub(ISessionOpenTelemetryService, upcastPartial({ + withOpenRequest: (async (_source: string, _token: unknown, open: (attempt: unknown) => Promise) => open({})) as ISessionOpenTelemetryService['withOpenRequest'], + sessionResolved: () => { }, sessionActivated: () => { }, sessionLoaded: () => { }, + })); + instantiationService.stub(IWorkspaceContextService, upcastPartial({})); + instantiationService.stub(IEditorService, upcastPartial({})); + const service = disposables.add(instantiationService.createInstance(EditorWindowSessionsService)); + + const openingA = service.openSession(a.resource); + await service.openSession(b.resource); + resolvingA.complete(a.resource); + await openingA; + registration.dispose(); + + assert.deepStrictEqual(opened, ['b']); + }); }); From be7c5c2b2ecf1b86adb7d7f35cee31123267fddc Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:47:39 +0100 Subject: [PATCH 088/165] Chat part: a retargeted composer offers its folder; read state for several chat targets; New Session keeps its model, mode and Dev Container - A composer's allowed folders include the folder it aims at now, also after its host retargets it.\n- Mark as Read/Unread handles several targets, each a session or a chat of one.\n- The default presenter's new chat carries the target's model, mode and Dev Container (IChatViewNewChatTarget). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/chatViewHost.ts | 6 ++++++ .../browser/agentsView/agentsChatViewHost.ts | 2 +- .../browser/chatEditor/chatWindowComposer.ts | 3 ++- .../editorWindowSessionsPresenter.ts | 2 +- .../browser/views/sessionsListActions.ts | 20 +++++++++++++------ .../agentsView/agentsChatPartActions.test.ts | 4 ++-- .../chatEditor/chatWindowComposer.test.ts | 13 ++++++++++++ .../editorWindowSessionsRuntime.test.ts | 8 ++++---- 8 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts index 803b409e773f1e..ff1e89f028b3ff 100644 --- a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts +++ b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts @@ -60,6 +60,12 @@ export interface IChatViewNewChatTarget { readonly folder?: URI; readonly providerId?: string; readonly sessionType?: string; + /** The model the new chat starts with, where its session type offers it. */ + readonly modelId?: string; + /** The mode the new chat shows, such as an agent's custom agent, where its session type offers it. */ + readonly modeId?: string; + /** Whether the session of the new chat runs in a Dev Container. */ + readonly requireDevContainer?: boolean; } /** How {@link IChatViewHost.newChat} starts a new chat. */ diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts index 9b0c2a342a159a..ac5c4a44fb45bb 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts @@ -96,7 +96,7 @@ export class AgentsChatViewHost implements IChatViewHost { if (!this._ownsNewChat.get() || !this._available) { return false; } - const aimed = target ? { folderUri: target.folder, providerId: target.providerId, sessionTypeId: target.sessionType } : this._composerTarget(); + const aimed = target ? { folderUri: target.folder, providerId: target.providerId, sessionTypeId: target.sessionType, modelId: target.modelId, modeId: target.modeId, requireDevContainer: target.requireDevContainer } : this._composerTarget(); await this._chatPart.openComposer(aimed, { newComposer: true, preserveFocus: options?.preserveFocus }); return true; } diff --git a/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts b/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts index 9cc337c6d2b971..af1c202015f7ea 100644 --- a/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts +++ b/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts @@ -255,9 +255,10 @@ export class ChatWindowComposerModel extends Disposable implements INewSessionDr this.groupId = init.groupId; this._getFolderPresentation = init.getFolderPresentation; const windowFolders = init.allowedFolders; - const targetFolder = init.target?.folderUri; + // Also the folder it aims at, such as one its host retargets it to outside the window's folders this.allowedFolders = windowFolders && derived(this, reader => { const folders = windowFolders.read(reader); + const targetFolder = this._requestedTarget.read(reader)?.folderUri; return folders.length && targetFolder && !folders.some(folder => extUriBiasedIgnorePathCase.isEqual(folder, targetFolder)) ? [...folders, targetFolder] : folders; }); this._requestedTarget.set(init.target, undefined); diff --git a/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsPresenter.ts b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsPresenter.ts index 973f30a0d0d7dd..42fda0b38d1db1 100644 --- a/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsPresenter.ts +++ b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowSessionsPresenter.ts @@ -104,7 +104,7 @@ class ChatViewSessionsPresenter implements IEditorWindowSessionsPresenter { * such as the one of its sessions list, is aimed at `target`. */ async openComposer(target: INewSessionTarget | undefined, options?: IEditorWindowPresenterOpenOptions): Promise { - const aimed = target && { folder: target.folderUri, providerId: target.providerId, sessionType: target.sessionTypeId }; + const aimed = target && { folder: target.folderUri, providerId: target.providerId, sessionType: target.sessionTypeId, modelId: target.modelId, modeId: target.modeId, requireDevContainer: target.requireDevContainer }; await this._chatViewHostService.newChat(aimed, { ownChat: true, preserveFocus: options?.preserveFocus }); } diff --git a/src/vs/workbench/contrib/sessions/browser/views/sessionsListActions.ts b/src/vs/workbench/contrib/sessions/browser/views/sessionsListActions.ts index e583e61dcdd23a..8e08828a610446 100644 --- a/src/vs/workbench/contrib/sessions/browser/views/sessionsListActions.ts +++ b/src/vs/workbench/contrib/sessions/browser/views/sessionsListActions.ts @@ -642,10 +642,18 @@ function setSessionListChatReadState(sessionsManagementService: ISessionsManagem return isRead ? sessionsManagementService.markChatRead(session, chat) : sessionsManagementService.markChatUnread(session, chat); } -async function markSessionListTargetsRead(accessor: ServicesAccessor, context: ISession | ISession[] | ISessionChatItem | undefined, isRead: boolean): Promise { +async function markSessionListTargetsRead(accessor: ServicesAccessor, context: ISession | ISessionChatItem | readonly (ISession | ISessionChatItem)[] | undefined, isRead: boolean): Promise { const sessionsManagementService = accessor.get(ISessionsManagementService); - if (context && !Array.isArray(context) && hasKey(context, { session: true, chat: true })) { - await setSessionListChatReadState(sessionsManagementService, context, isRead); + const isChatItem = (target: ISession | ISessionChatItem): target is ISessionChatItem => hasKey(target, { session: true, chat: true }); + if (context && !Array.isArray(context) && isChatItem(context as ISession | ISessionChatItem)) { + await setSessionListChatReadState(sessionsManagementService, context as ISessionChatItem, isRead); + return; + } + // Several targets, each a session or a chat of a session, such as the chat tabs a command runs for + if (Array.isArray(context) && context.some(isChatItem)) { + for (const target of context) { + await (isChatItem(target) ? setSessionListChatReadState(sessionsManagementService, target, isRead) : isRead ? sessionsManagementService.markRead(target) : sessionsManagementService.markUnread(target)); + } return; } if (!context) { @@ -657,7 +665,7 @@ async function markSessionListTargetsRead(accessor: ServicesAccessor, context: I return; } } - const sessions = context ? (Array.isArray(context) ? context : [context]) : getFocusedSessionListTargets(accessor) ?? []; + const sessions = (context ? (Array.isArray(context) ? context : [context]) : getFocusedSessionListTargets(accessor) ?? []) as readonly ISession[]; for (const session of sessions) { await (isRead ? sessionsManagementService.markRead(session) : sessionsManagementService.markUnread(session)); } @@ -670,8 +678,8 @@ async function markSessionListTargetsRead(accessor: ServicesAccessor, context: I */ export function registerSessionsListReadStateCommands(): IDisposable { const store = new DisposableStore(); - store.add(CommandsRegistry.registerCommand(SESSIONS_LIST_MARK_READ_COMMAND_ID, (accessor, context?: ISession | ISession[] | ISessionChatItem) => markSessionListTargetsRead(accessor, context, true))); - store.add(CommandsRegistry.registerCommand(SESSIONS_LIST_MARK_UNREAD_COMMAND_ID, (accessor, context?: ISession | ISession[] | ISessionChatItem) => markSessionListTargetsRead(accessor, context, false))); + store.add(CommandsRegistry.registerCommand(SESSIONS_LIST_MARK_READ_COMMAND_ID, (accessor, context?: ISession | ISessionChatItem | readonly (ISession | ISessionChatItem)[]) => markSessionListTargetsRead(accessor, context, true))); + store.add(CommandsRegistry.registerCommand(SESSIONS_LIST_MARK_UNREAD_COMMAND_ID, (accessor, context?: ISession | ISessionChatItem | readonly (ISession | ISessionChatItem)[]) => markSessionListTargetsRead(accessor, context, false))); return store; } diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPartActions.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPartActions.test.ts index f1a9907236124e..dea25c48b59950 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPartActions.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPartActions.test.ts @@ -143,13 +143,13 @@ suite('Agents chat part actions', () => { const before = CommandsRegistry.getCommand(SESSIONS_LIST_MARK_READ_COMMAND_ID); const registration = registerAgentsChatPartActions(); // A chat tab of a side chat and of a session's main chat run the agent sessions' commands - for (const [action, context] of [[read, { session, chat: side }], [unread, { session, chat: main }]] as const) { + for (const [action, context] of [[read, { session, chat: side }], [unread, { session, chat: main }], [unread, [{ session, chat: side }, { session, chat: main }]]] as const) { await instantiationService.invokeFunction(accessor => action.run(accessor, context as never)); } registration.dispose(); assert.deepStrictEqual({ marked, restoredAfterDispose: CommandsRegistry.getCommand(SESSIONS_LIST_MARK_READ_COMMAND_ID) === before }, { - marked: ['read chat side', 'unread s1'], + marked: ['read chat side', 'unread s1', 'unread chat side', 'unread s1'], restoredAfterDispose: true, }); }); diff --git a/src/vs/workbench/contrib/sessions/test/browser/chatEditor/chatWindowComposer.test.ts b/src/vs/workbench/contrib/sessions/test/browser/chatEditor/chatWindowComposer.test.ts index 90bb5a385a1e73..e16020368646e7 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/chatEditor/chatWindowComposer.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/chatEditor/chatWindowComposer.test.ts @@ -537,6 +537,19 @@ suite('Chat window composer', () => { }); }); + test('a composer that follows its host to a folder outside the window\'s folders offers that folder, so its picker shows where its draft is', () => { + const store = disposables.add(new DisposableStore()); + const { instantiationService } = createServices(store); + const composers = store.add(instantiationService.createInstance(ChatWindowComposers, async () => undefined)); + const composer = store.add(composers.create(URI.parse('vscode-agents-composer:/allowed'), { target: { folderUri: URI.file('/repo') }, allowedFolders: constObservable([URI.file('/repo')]) }, async () => { })); + const allowed = () => composer.allowedFolders?.get().map(folder => folder.path); + + const before = allowed(); + composer.retargetFolder(URI.file('/repo.worktrees/a'), URI.file('/repo')); + + assert.deepStrictEqual({ before, after: allowed() }, { before: ['/repo'], after: ['/repo', '/repo.worktrees/a'] }); + }); + test('a composer does not follow its host to another folder while it sends: from the user\'s submit, before its request is accepted, until the request ends', () => { const store = disposables.add(new DisposableStore()); const { onDidAccept, onDidEnd, slots, createComposer } = createServices(store); diff --git a/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts b/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts index 55f0fc2bf8a415..8753bce46804ce 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts @@ -125,16 +125,16 @@ suite('EditorWindowSessionsPresenterService', () => { const calls: string[] = []; const service = disposables.add(new EditorWindowSessionsPresenterService( upcastPartial({ - newChat: async (target, options) => { calls.push(`newChat ${target?.folder?.path} provider=${target?.providerId} type=${target?.sessionType} ownChat=${options?.ownChat} preserveFocus=${options?.preserveFocus}`); return true; }, + newChat: async (target, options) => { calls.push(`newChat ${target?.folder?.path} provider=${target?.providerId} type=${target?.sessionType} model=${target?.modelId} mode=${target?.modeId} devContainer=${target?.requireDevContainer} ownChat=${options?.ownChat} preserveFocus=${options?.preserveFocus}`); return true; }, }), )); await service.presenter.get().openComposer(undefined, { preserveFocus: true }); - await service.presenter.get().openComposer({ folderUri: URI.file('/repo.worktrees/a'), providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }); + await service.presenter.get().openComposer({ folderUri: URI.file('/repo.worktrees/a'), providerId: 'local-agent-host', sessionTypeId: 'copilotcli', modelId: 'model', modeId: 'mode', requireDevContainer: true }); assert.deepStrictEqual(calls, [ - 'newChat undefined provider=undefined type=undefined ownChat=true preserveFocus=true', - 'newChat /repo.worktrees/a provider=local-agent-host type=copilotcli ownChat=true preserveFocus=undefined', + 'newChat undefined provider=undefined type=undefined model=undefined mode=undefined devContainer=undefined ownChat=true preserveFocus=true', + 'newChat /repo.worktrees/a provider=local-agent-host type=copilotcli model=model mode=mode devContainer=true ownChat=true preserveFocus=undefined', ]); }); From 38e75c3a27b2c418e1b67a5351e66f5276b0dd11 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:50:21 +0100 Subject: [PATCH 089/165] Worktree infra: sources of where local agent host sessions work, besides the Chat view's sessions list IAgentHostWorktreeSessionSources lets another session layer, such as the editor window's, nominate where its sessions and chats work. AgentHostWorktreeLocationProvider offers the union of the Chat view's sessions and every source, deduplicated, and still accepts only a linked checkout that git names back. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostWorktreeLocations.ts | 19 +++- .../agentHostWorktreeSessionSources.ts | 99 +++++++++++++++++++ .../agentHostWorktreeLocations.test.ts | 29 +++++- 3 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeSessionSources.ts diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts index 2e51fd0ea2717f..f42facd1514749 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts @@ -7,6 +7,7 @@ import { raceCancellation } from '../../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../../../base/common/map.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { isAbsolute } from '../../../../../../base/common/path.js'; import { basename, dirname, extUriBiasedIgnorePathCase, joinPath, normalizePath } from '../../../../../../base/common/resources.js'; @@ -19,6 +20,7 @@ import { readFolderGitCheckout } from '../../../../../services/workspaces/common import { IChatSessionsService, isLocalAgentHostTarget } from '../../../common/chatSessionsService.js'; import { IAgentSession } from '../agentSessionsModel.js'; import { IAgentSessionsService } from '../agentSessionsService.js'; +import { IAgentHostWorktreeSessionSources } from './agentHostWorktreeSessionSources.js'; /** The id of the provider of the worktrees that the local agent host's sessions work in. */ export const AGENT_HOST_WORKTREE_LOCATIONS_PROVIDER_ID = 'agentHost.sessionWorktrees'; @@ -60,6 +62,7 @@ export class AgentHostWorktreeLocationProvider extends Disposable implements IWo constructor( @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, @IChatSessionsService private readonly _chatSessionsService: IChatSessionsService, + @IAgentHostWorktreeSessionSources private readonly _sessionSources: IAgentHostWorktreeSessionSources, @IFileService private readonly _fileService: IFileService, @ILogService private readonly _logService: ILogService, ) { @@ -86,9 +89,19 @@ export class AgentHostWorktreeLocationProvider extends Disposable implements IWo return []; } } - const candidates = getAgentHostSessionWorkingDirectories(model.sessions) - .filter(directory => !extUriBiasedIgnorePathCase.isEqual(directory, primary)); - const locations = await Promise.all(candidates.map(async candidate => { + // Also where the sessions that other sources know work, such as those of the editor window's session layer: they + // only nominate a working directory, which is a location only once git links it to the primary below + await raceCancellation(this._sessionSources.whenReady(token), token); + if (token.isCancellationRequested) { + return []; + } + const candidates = new ResourceMap(uri => extUriBiasedIgnorePathCase.getComparisonKey(uri)); + for (const directory of [...getAgentHostSessionWorkingDirectories(model.sessions), ...this._sessionSources.getWorkingDirectories()]) { + if (!extUriBiasedIgnorePathCase.isEqual(directory, primary)) { + candidates.set(directory, directory); + } + } + const locations = await Promise.all([...candidates.values()].map(async candidate => { try { return await this._resolve(worktreesDir, primary, candidate); } catch (error) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeSessionSources.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeSessionSources.ts new file mode 100644 index 00000000000000..989a2982e09286 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeSessionSources.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Disposable, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { InstantiationType, registerSingleton } from '../../../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js'; + +/** + * Knows where local agent host sessions work, such as the sessions of a session layer that the Chat view's sessions list + * does not list. It only nominates working directories: whether one is a location of the window's folder, and inherits + * its trust, is decided by `AgentHostWorktreeLocationProvider` alone. + */ +export interface IAgentHostWorktreeSessionSource { + /** Where the chat of `chatResource` works, if this source knows it. */ + getWorkingDirectory(chatResource: URI): URI | undefined; + /** Where the non-archived local agent host sessions it knows work. */ + getWorkingDirectories(): readonly URI[]; + /** Resolves once it knows its sessions, such as after their first load. */ + whenReady?(token: CancellationToken): Promise; + /** Fires as its sessions, or where they work, change. */ + readonly onDidChange: Event; +} + +export const IAgentHostWorktreeSessionSources = createDecorator('agentHostWorktreeSessionSources'); + +/** The {@link IAgentHostWorktreeSessionSource sources} of where local agent host sessions work, besides the Chat view's sessions list. */ +export interface IAgentHostWorktreeSessionSources { + readonly _serviceBrand: undefined; + + /** Fires as a source is registered or unregistered, or as one changes. */ + readonly onDidChange: Event; + + /** Counts `source` among the sources until the returned disposable is disposed. */ + registerSource(source: IAgentHostWorktreeSessionSource): IDisposable; + + /** Where the chat of `chatResource` works, as the first source that knows it has it. */ + getWorkingDirectory(chatResource: URI): URI | undefined; + + /** Where the sessions of all sources work. */ + getWorkingDirectories(): readonly URI[]; + + /** Resolves once every source knows its sessions. */ + whenReady(token: CancellationToken): Promise; +} + +export class AgentHostWorktreeSessionSources extends Disposable implements IAgentHostWorktreeSessionSources { + + declare readonly _serviceBrand: undefined; + + private readonly _sources = new Set<{ readonly source: IAgentHostWorktreeSessionSource; readonly listener: IDisposable }>(); + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + + registerSource(source: IAgentHostWorktreeSessionSource): IDisposable { + const registration = { source, listener: source.onDidChange(() => this._onDidChange.fire()) }; + this._sources.add(registration); + this._onDidChange.fire(); + return toDisposable(() => { + if (this._sources.delete(registration)) { + registration.listener.dispose(); + this._onDidChange.fire(); + } + }); + } + + getWorkingDirectory(chatResource: URI): URI | undefined { + for (const { source } of this._sources) { + const workingDirectory = source.getWorkingDirectory(chatResource); + if (workingDirectory) { + return workingDirectory; + } + } + return undefined; + } + + getWorkingDirectories(): readonly URI[] { + return [...this._sources].flatMap(({ source }) => source.getWorkingDirectories()); + } + + async whenReady(token: CancellationToken): Promise { + await Promise.all([...this._sources].map(({ source }) => source.whenReady?.(token))); + } + + override dispose(): void { + for (const { listener } of this._sources) { + listener.dispose(); + } + this._sources.clear(); + super.dispose(); + } +} + +registerSingleton(IAgentHostWorktreeSessionSources, AgentHostWorktreeSessionSources, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts index fc7745c6093551..ca2d7f63854b67 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts @@ -21,6 +21,7 @@ import { AGENT_HOST_WORKTREE_LOCATIONS_PROVIDER_ID, AgentHostWorktreeLocationPro import { IAgentSession } from '../../../../browser/agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../../../../browser/agentSessions/agentSessionsService.js'; import { IChatSessionsService, SessionType } from '../../../../common/chatSessionsService.js'; +import { AgentHostWorktreeSessionSources, IAgentHostWorktreeSessionSources } from '../../../../browser/agentSessions/agentHost/agentHostWorktreeSessionSources.js'; suite('AgentHostWorktreeLocations', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -80,11 +81,13 @@ suite('AgentHostWorktreeLocations', () => { }; instantiationService.stub(IAgentSessionsService, upcastPartial({ model: upcastPartial(model) })); instantiationService.stub(IChatSessionsService, upcastPartial({ getRegisteredChatSessionItemProviders: () => ['remote', SessionType.AgentHostCopilot] })); + const sources = store.add(new AgentHostWorktreeSessionSources()); + instantiationService.stub(IAgentHostWorktreeSessionSources, sources); const provider = store.add(instantiationService.createInstance(AgentHostWorktreeLocationProvider)); // Another provider resolved first model.resolved = true; onDidResolve.fire('remote'); - return Object.assign(provider, { resolving }); + return Object.assign(provider, { resolving, sources }); } const repository = { @@ -245,4 +248,28 @@ suite('AgentHostWorktreeLocations', () => { trusted: ['/work/vscode.worktrees/feature'], }); }); + + test('also offers the worktrees that sessions of another source work in, such as the editor window\'s session layer, but only those git links back', async () => { + const provider = createProvider(repository, [session(SessionType.AgentHostCopilot, '/work/vscode.worktrees/feature')]); + const ready = new DeferredPromise(); + const onDidChange = store.add(new Emitter()); + const nominated = ['/work/vscode.worktrees/relative', '/work/vscode.worktrees/feature', '/work/impostor', '/work/tools.worktrees/fix', '/work/vscode']; + store.add(provider.sources.registerSource({ + getWorkingDirectory: () => undefined, + getWorkingDirectories: () => nominated.map(path => URI.file(path)), + whenReady: () => ready.p, + onDidChange: onDidChange.event, + })); + let offered: string[] | undefined; + const providing = provider.provideLocations(URI.file('/work/vscode'), CancellationToken.None).then(locations => offered = locations.map(location => location.uri.path)); + await new Promise(resolve => setTimeout(resolve, 0)); + const beforeReady = offered; + await ready.complete(); + await providing; + + assert.deepStrictEqual({ beforeReady, offered }, { + beforeReady: undefined, + offered: ['/work/vscode.worktrees/feature', '/work/vscode.worktrees/relative'], + }); + }); }); From 68224e978c14eb8eafd57552a342467b2f29cff8 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:52:52 +0100 Subject: [PATCH 090/165] chat: a New Chat of the Chat view's own is a transition that a later one supersedes; its target carries model, mode and Dev Container - A New Chat with ownChat cancels a load, restore or New Chat under way before it awaits, and a later one supersedes it: then it neither shows its new chat nor aims the composer. - IChatViewNewChatTarget gains modelId, modeId and requireDevContainer (from #348309 be7c5c2b2ec). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/chatViewHost.ts | 6 ++++ .../widgetHosts/viewPane/chatViewPane.ts | 12 ++++++- .../widgetHosts/viewPane/chatViewPane.test.ts | 35 +++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts index 803b409e773f1e..ff1e89f028b3ff 100644 --- a/src/vs/workbench/contrib/chat/browser/chatViewHost.ts +++ b/src/vs/workbench/contrib/chat/browser/chatViewHost.ts @@ -60,6 +60,12 @@ export interface IChatViewNewChatTarget { readonly folder?: URI; readonly providerId?: string; readonly sessionType?: string; + /** The model the new chat starts with, where its session type offers it. */ + readonly modelId?: string; + /** The mode the new chat shows, such as an agent's custom agent, where its session type offers it. */ + readonly modeId?: string; + /** Whether the session of the new chat runs in a Dev Container. */ + readonly requireDevContainer?: boolean; } /** How {@link IChatViewHost.newChat} starts a new chat. */ diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 30727ac74ffa08..8aacd29ab2f4d2 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -918,9 +918,19 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { */ async newChat(target?: IChatViewNewChatTarget, options?: IChatViewHostNewChatOptions): Promise { if (options?.ownChat) { + // A transition of the Chat view, as loading a chat is: it supersedes a load, restore or New Chat under way, and a + // later one supersedes it, which then neither shows its new chat nor aims the composer + this.loadSessionCts.value?.cancel(); + this._applyModelCts.value?.cancel(); + const transition = this.loadSessionCts.value = new CancellationTokenSource(); if (!this.composerShown) { // Its own new chat, rather than the chat widget that New Chat acts on, such as one that has focus elsewhere - await this.clear(); + this.updateViewState(); + await this.showModel(transition.token); + if (transition.token.isCancellationRequested) { + return true; + } + this.updateActions(); } } else if (!this.providedComposer) { return false; diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index 0d38b1c68735a6..61fbc314133734 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -700,6 +700,41 @@ suite('ChatViewPane session transitions', () => { }); })); + test('of two New Chats of its own under way, the later one shows its new chat and aims the composer, also as the earlier one completes last', async () => { + const rig = createPane(); + const disposedNew: string[] = []; + const newChat = (name: string) => { + const model = upcastPartial({ sessionResource: URI.from({ scheme: SessionType.AgentHostCopilot, path: `/untitled-${name}` }) }); + return { model, ref: Object.assign(toDisposable(() => disposedNew.push(name)), { object: model }) as IChatModelReference, acquired: new DeferredPromise<{ modelRef: IChatModelReference }>() }; + }; + const a = newChat('a'); + const b = newChat('b'); + const acquisitions = [a, b]; + const composerCalls: string[] = []; + Object.assign(rig.pane, { + acquireDefaultNewSession: () => acquisitions.shift()!.acquired.p, + updateViewState: () => { }, + providedComposer: { newChat: (target?: { folder?: URI }) => composerCalls.push(`newChat ${target?.folder?.path}`), focus: () => composerCalls.push('focus') }, + composerShown: false, + }); + const run = Reflect.get(ChatViewPane.prototype, 'newChat') as (this: object, target: { folder: URI }, options: { ownChat: boolean; preserveFocus: boolean }) => Promise; + const first = run.call(rig.pane, { folder: URI.file('/a') }, { ownChat: true, preserveFocus: true }); + const second = run.call(rig.pane, { folder: URI.file('/b') }, { ownChat: true, preserveFocus: true }); + await b.acquired.complete({ modelRef: b.ref }); + const takenLater = await second; + await a.acquired.complete({ modelRef: a.ref }); + const takenEarlier = await first; + + assert.deepStrictEqual({ takenEarlier, takenLater, bindings: rig.bindings.map(resource => resource?.path), composerCalls, disposedNew, shown: rig.widget.viewModel?.sessionResource.path }, { + takenEarlier: true, + takenLater: true, + bindings: ['/untitled-b'], + composerCalls: ['newChat /b'], + disposedNew: ['a'], + shown: '/untitled-b', + }); + }); + for (const untitled of [true, false]) { test(`${untitled ? 'new chats stay bound' : 'saved chats still clear'} while a slow load is pending`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { const rig = createPane(); From 6d4c3e8549a0a00cb1729d8f4266b60b29ccf839 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:53:50 +0100 Subject: [PATCH 091/165] Worktree support: follow and offer the worktrees of the editor window's session layer Chats of sessions that the Chat view's sessions list does not list, such as those created in the chat part or a Sessions view, are followed through IAgentHostWorktreeSessionSources, and the session layer registers a source over ISessionsManagementService while worktree support and AI features are on. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostWorktree.contribution.ts | 22 ++++++- .../agentHostWorktreeFolderChoices.ts | 6 +- .../agentHost/agentHostWorktree.test.ts | 36 ++++++++++- .../agentHostWorktreeFolderChoices.test.ts | 2 + .../agentHostWorktreeComposerFolders.ts | 63 ++++++++++++++++++- .../agentHostWorktreeComposerFolders.test.ts | 56 ++++++++++++++++- 6 files changed, 177 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts index 51e5cf3d969c9f..823462984356af 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts @@ -6,6 +6,7 @@ import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { toErrorMessage } from '../../../../../../base/common/errorMessage.js'; +import { Event } from '../../../../../../base/common/event.js'; import { Disposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap, ResourceSet } from '../../../../../../base/common/map.js'; import { Schemas } from '../../../../../../base/common/network.js'; @@ -37,6 +38,7 @@ import { IChatSessionsService, isLocalAgentHostTarget } from '../../../common/ch import { ChatConfiguration } from '../../../common/constants.js'; import { isUntitledChatSession, LocalChatSessionUri } from '../../../common/model/chatUri.js'; import { IChatViewHostService } from '../../chatViewHost.js'; +import { IAgentHostWorktreeSessionSources } from './agentHostWorktreeSessionSources.js'; import { IAgentSessionsService } from '../agentSessionsService.js'; import { AGENT_HOST_WORKTREE_LOCATIONS_PROVIDER_ID, AgentHostWorktreeLocationProvider } from './agentHostWorktreeLocations.js'; @@ -122,6 +124,7 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe constructor( @IChatViewHostService private readonly _chatViewHostService: IChatViewHostService, @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, + @IAgentHostWorktreeSessionSources private readonly _sessionSources: IAgentHostWorktreeSessionSources, @IChatSessionsService chatSessionsService: IChatSessionsService, @IConfigurationService private readonly _configurationService: IConfigurationService, @IContextKeyService private readonly _contextKeyService: IContextKeyService, @@ -274,6 +277,11 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe /** The working directory of the local agent host session of `resource`, as the sessions list has it. */ private _workingDirectory(resource: URI): URI | undefined { + // A chat of another session layer, such as the editor window's, as a source has it + const fromSource = this._sessionSources.getWorkingDirectory(resource); + if (fromSource) { + return fromSource; + } if (!isLocalAgentHostTarget(resource.scheme)) { return undefined; } @@ -281,12 +289,20 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe return typeof path === 'string' && path ? URI.file(path) : undefined; } + /** + * Whether the chat of `resource` may work in a folder that the folder follows: any but a local chat, such as an agent + * host session of any session layer, whose working directory may only become known once its first request ran. + */ + private _isFollowable(resource: URI): boolean { + return !LocalChatSessionUri.isLocalSession(resource); + } + /** Follows the shown agent host session on its own as its working directory becomes known or changes. */ private _watchWorkingDirectory(shown: IShownChat): void { - if (!isLocalAgentHostTarget(shown.resource.scheme)) { + if (!this._isFollowable(shown.resource)) { return; } - this._sessionsListener.value = this._agentSessionsService.model.onDidChangeSessions(() => { + this._sessionsListener.value = Event.any(this._agentSessionsService.model.onDidChangeSessions, this._sessionSources.onDidChange)(() => { const workingDirectory = this._workingDirectory(shown.resource); if (this._shown !== shown || !workingDirectory || extUriBiasedIgnorePathCase.isEqual(workingDirectory, shown.workingDirectory)) { return; @@ -310,7 +326,7 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe let target: URI | undefined; if (LocalChatSessionUri.isLocalSession(shown.resource)) { target = primary; - } else if (isLocalAgentHostTarget(shown.resource.scheme)) { + } else if (this._isFollowable(shown.resource)) { shown.workingDirectory = this._workingDirectory(shown.resource); // Not listed with its working directory yet, such as a session its first request just started: it follows as it is this._watchWorkingDirectory(shown); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts index 5abee00c49cfce..6c4ac311309fa6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts @@ -7,7 +7,7 @@ import { RunOnceScheduler } from '../../../../../../base/common/async.js'; import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { onUnexpectedError } from '../../../../../../base/common/errors.js'; -import { Emitter } from '../../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { basename, extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -22,6 +22,7 @@ import { getCurrentFolderLocationDescription, getNewWorktreeLabel, groupFolderLo import { ChatConfiguration } from '../../../common/constants.js'; import { IAgentSessionsService } from '../agentSessionsService.js'; import { IAgentHostNewSessionFolderService } from './agentHostNewSessionFolderService.js'; +import { IAgentHostWorktreeSessionSources } from './agentHostWorktreeSessionSources.js'; import { getLocalAgentHostSessionProvider } from './agentHostSessionUri.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; @@ -57,13 +58,14 @@ export class AgentHostWorktreeFolderChoices extends Disposable { @IAgentHostNewSessionFolderService private readonly _newSessionFolderService: IAgentHostNewSessionFolderService, @IAgentHostUntitledProvisionalSessionService private readonly _provisional: IAgentHostUntitledProvisionalSessionService, @IAgentSessionsService agentSessionsService: IAgentSessionsService, + @IAgentHostWorktreeSessionSources sessionSources: IAgentHostWorktreeSessionSources, ) { super(); this._register(_folderLocationService.onDidChangeProviders(() => this.refresh())); this._register(_folderLocationService.onDidChangeLocation(() => this.refresh())); // A session can come to work in a new worktree meanwhile, such as one in the background const refreshForSessions = this._register(new RunOnceScheduler(() => this.refresh(), 500)); - this._register(agentSessionsService.model.onDidChangeSessions(() => { + this._register(Event.any(agentSessionsService.model.onDidChangeSessions, sessionSources.onDidChange)(() => { if (this.isActive()) { refreshForSessions.schedule(); } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts index 77ee519cd5d95d..5b5a2032049a13 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts @@ -36,6 +36,7 @@ import { IAgentSessionsService } from '../../../../browser/agentSessions/agentSe import { IChatViewHostService, IChatViewHostShownSession } from '../../../../browser/chatViewHost.js'; import { ChatContextKeys } from '../../../../common/actions/chatContextKeys.js'; import { IChatSessionCommitEvent, IChatSessionsService, SessionType } from '../../../../common/chatSessionsService.js'; +import { AgentHostWorktreeSessionSources, IAgentHostWorktreeSessionSources } from '../../../../browser/agentSessions/agentHost/agentHostWorktreeSessionSources.js'; import { ChatConfiguration } from '../../../../common/constants.js'; import { LocalChatSessionUri } from '../../../../common/model/chatUri.js'; @@ -89,6 +90,16 @@ suite('AgentHostWorktree', () => { }, })); instantiationService.stub(IChatSessionsService, upcastPartial({ onDidCommitSession: onDidCommitSession.event })); + // Where the chats of the editor window's session layer work, which the Chat view's sessions list does not list + const sessionLayerDirectories = new Map(); + const onDidChangeSessionLayer = store.add(new Emitter()); + const sessionSources = store.add(new AgentHostWorktreeSessionSources()); + store.add(sessionSources.registerSource({ + getWorkingDirectory: (chat: URI) => { const path = sessionLayerDirectories.get(chat.toString()); return path ? URI.file(path) : undefined; }, + getWorkingDirectories: () => [...sessionLayerDirectories.values()].map(path => URI.file(path)), + onDidChange: onDidChangeSessionLayer.event, + })); + instantiationService.stub(IAgentHostWorktreeSessionSources, sessionSources); instantiationService.stub(IAgentSessionsService, upcastPartial({ getSession: (resource: URI) => { const path = workingDirectories.get(resource.toString()); @@ -162,7 +173,7 @@ suite('AgentHostWorktree', () => { /** The user moves the folder in the window's title, which the workspace makes like any other move. */ const userStartsMoveTo = (location: URI) => setFolderLocation(location, { reason: 'user' }); const commit = (original: URI, committed: URI) => onDidCommitSession.fire({ original, committed }); - return { show, workingDirectories, onDidChangeSessions, moves, applied, opened, providers, triggers: telemetryService.triggers, setEnabled, userMovesTo, userStartsMoveTo, commit, contribution, marks }; + return { show, workingDirectories, onDidChangeSessions, sessionLayerDirectories, onDidChangeSessionLayer, moves, applied, opened, providers, triggers: telemetryService.triggers, setEnabled, userMovesTo, userStartsMoveTo, commit, contribution, marks }; } const settle = () => new Promise(resolve => setTimeout(resolve, 0)); @@ -512,4 +523,27 @@ suite('AgentHostWorktree', () => { assert.deepStrictEqual(feature.applied, ['/work/vscode', '/work/vscode.worktrees/created']); }); + + test('follows a chat of the editor window\'s session layer, which the Chat view\'s sessions list does not list: as the user opens it, and on its own as its session comes to work in a worktree', async () => { + // Chats as the editor window's session layer names them + const chat = (id: string) => URI.from({ scheme: 'ahp-chat', path: `/${id}` }); + const feature = createWorktree({ enabled: true }); + feature.sessionLayerDirectories.set(chat('existing').toString(), worktree.fsPath); + feature.show(chat('existing')); + await settle(); + const opened = [...feature.moves]; + feature.show(localChat); + await settle(); + // A new chat whose first request created a worktree for its session + feature.show(chat('new')); + await settle(); + feature.sessionLayerDirectories.set(chat('new').toString(), created.fsPath); + feature.onDidChangeSessionLayer.fire(); + await settle(); + + assert.deepStrictEqual({ opened, moves: feature.moves }, { + opened: ['/work/vscode.worktrees/feature'], + moves: ['/work/vscode.worktrees/feature', '/work/vscode', '/work/vscode.worktrees/created (automatic)'], + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts index a3c77bab8dd1b2..e7febd9dd60e5b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts @@ -19,6 +19,7 @@ import { IAgentHostNewSessionFolderService } from '../../../../browser/agentSess import { IAgentHostUntitledProvisionalSessionService } from '../../../../browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; import { AgentHostWorktreeFolderChoices } from '../../../../browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.js'; import { IAgentSessionsService } from '../../../../browser/agentSessions/agentSessionsService.js'; +import { AgentHostWorktreeSessionSources, IAgentHostWorktreeSessionSources } from '../../../../browser/agentSessions/agentHost/agentHostWorktreeSessionSources.js'; import { SessionType } from '../../../../common/chatSessionsService.js'; import { ChatConfiguration } from '../../../../common/constants.js'; @@ -67,6 +68,7 @@ suite('AgentHostWorktreeFolderChoices', () => { }, })); instantiationService.stub(IAgentSessionsService, upcastPartial({ model: upcastPartial({ onDidChangeSessions: onDidChangeSessions.event }) })); + instantiationService.stub(IAgentHostWorktreeSessionSources, store.add(new AgentHostWorktreeSessionSources())); const choices = store.add(instantiationService.createInstance(AgentHostWorktreeFolderChoices, () => session)); await new Promise(resolve => setTimeout(resolve, 0)); const actions = (selected: URI) => choices.getActions(upcastPartial({}), selected, () => { }); diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts index c2f7ffd5a13481..d6d606d763abc0 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts @@ -7,7 +7,7 @@ import { CancellationTokenSource } from '../../../../../base/common/cancellation import { onUnexpectedError } from '../../../../../base/common/errors.js'; import { Emitter } from '../../../../../base/common/event.js'; import { Disposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { autorun, derived, IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { autorun, derived, IObservable, observableSignalFromEvent, observableValue } from '../../../../../base/common/observable.js'; import { extUriBiasedIgnorePathCase } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -22,6 +22,10 @@ import { ChatContextKeys } from '../../../chat/common/actions/chatContextKeys.js import { ChatConfiguration } from '../../../chat/common/constants.js'; import { IAllowedFolderPresentation } from '../chat/sessionWorkspacePicker.js'; import { IEditorWindowComposerFoldersService } from '../editorWindow/editorWindowComposerFolders.js'; +import { IAgentHostWorktreeSessionSource, IAgentHostWorktreeSessionSources } from '../../../chat/browser/agentSessions/agentHost/agentHostWorktreeSessionSources.js'; +import { ISession } from '../../../../services/sessions/common/session.js'; +import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../common/agentHostSessionsProvider.js'; +import { ISessionsManagementService } from '../../common/sessionsManagement.js'; /** * The folders the chat part's new-session composers create their sessions in: the window's folders and, for the one local @@ -144,6 +148,60 @@ export class AgentHostWorktreeComposerFolders extends Disposable { } } +/** + * Where the local agent host sessions of the editor window's session layer (`ISessionsManagementService`) and their chats + * work, for worktree support, which the Chat view's sessions list does not have: a session created in the chat part or + * a Sessions view. It only nominates them; the worktree location provider decides which are locations of the folder. + */ +export class AgentHostWorktreeSessionLayerSource extends Disposable implements IAgentHostWorktreeSessionSource { + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + + constructor( + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + ) { + super(); + // Also as a session comes to work elsewhere, such as in the worktree its first request created + const sessionsChanged = observableSignalFromEvent(this, _sessionsManagementService.onDidChangeSessions); + let first = true; + this._register(autorun(reader => { + sessionsChanged.read(reader); + for (const session of _sessionsManagementService.getAllSessions()) { + session.isArchived.read(reader); + session.workspace.read(reader); + for (const chat of session.chats.read(reader)) { + chat.workspace.read(reader); + } + } + if (!first) { + this._onDidChange.fire(); + } + first = false; + })); + } + + getWorkingDirectory(chatResource: URI): URI | undefined { + const found = this._sessionsManagementService.getSessionForChatResource(chatResource); + if (!found || !isLocalAgentHostSession(found.session)) { + return undefined; + } + return (found.chat.workspace.get() ?? found.session.workspace.get())?.folders[0]?.workingDirectory; + } + + getWorkingDirectories(): readonly URI[] { + return this._sessionsManagementService.getAllSessions() + .filter(isLocalAgentHostSession) + .flatMap(session => [session.workspace.get(), ...session.chats.get().map(chat => chat.workspace.get())]) + .flatMap(workspace => workspace?.folders.map(folder => folder.workingDirectory) ?? []); + } +} + +/** Whether `session` is a non-archived session of the local agent host. */ +function isLocalAgentHostSession(session: ISession): boolean { + return session.providerId === LOCAL_AGENT_HOST_PROVIDER_ID && !session.isArchived.get(); +} + /** * While worktree support (`chat.editorWindow.enableWorktree`) and AI features are on, the editor window's new-session * composers, such as the chat part's and the Chat view's, also offer the locations of the window's folder, and follow the @@ -155,6 +213,7 @@ export class AgentHostWorktreeComposerFoldersContribution extends Disposable imp constructor( @IEditorWindowComposerFoldersService composerFoldersService: IEditorWindowComposerFoldersService, + @IAgentHostWorktreeSessionSources sessionSources: IAgentHostWorktreeSessionSources, @IConfigurationService configurationService: IConfigurationService, @IContextKeyService contextKeyService: IContextKeyService, @IInstantiationService instantiationService: IInstantiationService, @@ -171,6 +230,8 @@ export class AgentHostWorktreeComposerFoldersContribution extends Disposable imp if (!active.read(reader)) { return; } + // Where the sessions of the editor window's session layer work count as the folder's worktrees, and chats there follow + reader.store.add(sessionSources.registerSource(reader.store.add(instantiationService.createInstance(AgentHostWorktreeSessionLayerSource)))); const folders = reader.store.add(instantiationService.createInstance(AgentHostWorktreeComposerFolders)); reader.store.add(composerFoldersService.setComposerFolderOptions(() => { folders.refresh(); diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts index 103212680cb870..da13335aa01e6c 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts @@ -8,6 +8,7 @@ import { DeferredPromise, timeout } from '../../../../../../base/common/async.js import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -23,7 +24,11 @@ import { IWorkbenchEnvironmentService } from '../../../../../services/environmen import { ChatContextKeys } from '../../../../chat/common/actions/chatContextKeys.js'; import { ChatConfiguration } from '../../../../chat/common/constants.js'; import { EditorWindowComposerFoldersService, IEditorWindowComposerFolderOptions, IEditorWindowComposerFoldersService } from '../../../browser/editorWindow/editorWindowComposerFolders.js'; -import { AgentHostWorktreeComposerFolders, AgentHostWorktreeComposerFoldersContribution } from '../../../browser/agentsView/agentHostWorktreeComposerFolders.js'; +import { AgentHostWorktreeComposerFolders, AgentHostWorktreeComposerFoldersContribution, AgentHostWorktreeSessionLayerSource } from '../../../browser/agentsView/agentHostWorktreeComposerFolders.js'; +import { AgentHostWorktreeSessionSources, IAgentHostWorktreeSessionSources } from '../../../../chat/browser/agentSessions/agentHost/agentHostWorktreeSessionSources.js'; +import { ISessionsManagementService } from '../../../common/sessionsManagement.js'; +import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../common/agentHostSessionsProvider.js'; +import { IChat, ISession, ISessionWorkspace } from '../../../../../services/sessions/common/session.js'; suite('AgentHostWorktreeComposerFolders', () => { @@ -215,6 +220,8 @@ suite('AgentHostWorktreeComposerFolders', () => { instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IContextKeyService, contextKeyService); instantiationService.stub(IWorkbenchEnvironmentService, upcastPartial({ isSessionsWindow: false })); + instantiationService.stub(IAgentHostWorktreeSessionSources, store.add(new AgentHostWorktreeSessionSources())); + instantiationService.stub(ISessionsManagementService, upcastPartial({ onDidChangeSessions: Event.None, getAllSessions: () => [], getSessionForChatResource: () => undefined })); // No chat part: the service of the window's composers only instantiationService.stub(IEditorWindowComposerFoldersService, upcastPartial({ setComposerFolderOptions: provider => { calls.push('options'); options = provider; return toDisposable(() => { calls.push('options cleared'); options = undefined; }); }, @@ -256,6 +263,8 @@ suite('AgentHostWorktreeComposerFolders', () => { instantiationService.stub(IConfigurationService, new TestConfigurationService({ [ChatConfiguration.EditorWindowEnableWorktree]: true })); instantiationService.stub(IContextKeyService, contextKeyService); instantiationService.stub(IWorkbenchEnvironmentService, upcastPartial({ isSessionsWindow: false })); + instantiationService.stub(IAgentHostWorktreeSessionSources, store.add(new AgentHostWorktreeSessionSources())); + instantiationService.stub(ISessionsManagementService, upcastPartial({ onDidChangeSessions: Event.None, getAllSessions: () => [], getSessionForChatResource: () => undefined })); const service = instantiationService.createInstance(EditorWindowComposerFoldersService); instantiationService.stub(IEditorWindowComposerFoldersService, service); const retargeted: string[] = []; @@ -268,4 +277,49 @@ suite('AgentHostWorktreeComposerFolders', () => { assert.deepStrictEqual(retargeted, ['chat part: /work/vscode -> /work/vscode.worktrees/a', 'Chat view: /work/vscode -> /work/vscode.worktrees/a']); }); + + test('the session layer\'s source knows where its non-archived local agent host sessions and their chats work, and tells as that changes', () => { + const workspace = (path: string) => upcastPartial({ folders: [{ root: URI.file('/work/vscode'), workingDirectory: URI.file(path), name: 'vscode', description: undefined }] }); + const chat = (resource: string, path?: string) => upcastPartial({ resource: URI.from({ scheme: 'ahp-chat', path: resource }), workspace: observableValue('workspace', path ? workspace(path) : undefined) }); + const created = chat('/created'); + const session = (providerId: string, path: string, chats: IChat[], archived = false) => upcastPartial({ + providerId, + isArchived: observableValue('archived', archived), + workspace: observableValue('workspace', workspace(path)), + chats: observableValue('chats', chats), + }); + const sessions = [ + session(LOCAL_AGENT_HOST_PROVIDER_ID, '/work/vscode.worktrees/a', [chat('/a'), created]), + session(LOCAL_AGENT_HOST_PROVIDER_ID, '/work/vscode.worktrees/archived', [chat('/archived')], true), + session('remote-agent-host', '/work/vscode.worktrees/remote', [chat('/remote')]), + ]; + const onDidChangeSessions = store.add(new Emitter()); + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(ISessionsManagementService, upcastPartial({ + onDidChangeSessions: onDidChangeSessions.event, + getAllSessions: () => sessions, + getSessionForChatResource: (resource: URI) => { + for (const candidate of sessions) { + const found = candidate.chats.get().find(c => c.resource.toString() === resource.toString()); + if (found) { + return { session: candidate, chat: found }; + } + } + return undefined; + }, + })); + const source = store.add(instantiationService.createInstance(AgentHostWorktreeSessionLayerSource)); + let changes = 0; + store.add(source.onDidChange(() => changes++)); + const of = (path: string) => source.getWorkingDirectory(URI.from({ scheme: 'ahp-chat', path }))?.path; + const before = { a: of('/a'), archived: of('/archived'), remote: of('/remote'), all: source.getWorkingDirectories().map(uri => uri.path) }; + // The chat's first request created a worktree of its own + (created.workspace as ReturnType>).set(workspace('/work/vscode.worktrees/created'), undefined); + + assert.deepStrictEqual({ before, created: of('/created'), changes }, { + before: { a: '/work/vscode.worktrees/a', archived: undefined, remote: undefined, all: ['/work/vscode.worktrees/a'] }, + created: '/work/vscode.worktrees/created', + changes: 1, + }); + }); }); From a4a5bfcdfb0e548eac7569bc8df5c2db26144d0c Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:57:29 +0100 Subject: [PATCH 092/165] Editor window sessions: wait for a session to load only while it loads waitForState keeps its cancellation listener when its state holds already; the supersede test's loaded sessions showed it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentsView/editorWindowSessionsService.ts | 5 ++++- .../editorWindow/editorWindowSessionsRuntime.test.ts | 6 ++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsService.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsService.ts index 557ba000de3d26..43380d91aa70e2 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsService.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/editorWindowSessionsService.ts @@ -156,7 +156,10 @@ export class EditorWindowSessionsService extends Disposable implements ISessions this._sessionOpenTelemetryService.sessionResolved(attempt, session.resource, session.providerId, this._presenter.isShowing(chatResource), session.loading.get()); await this._presenter.openChat(session, undefined, { preserveFocus: options?.preserveFocus }); this._sessionOpenTelemetryService.sessionActivated(attempt, chatResource); - await waitForState(session.loading, loading => !loading, undefined, cts.token); + // Only while it loads: waitForState keeps its cancellation listener when the state holds already + if (session.loading.get()) { + await waitForState(session.loading, loading => !loading, undefined, cts.token); + } this._sessionOpenTelemetryService.sessionLoaded(attempt); }); } catch (error) { diff --git a/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts b/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts index 01ece4a1bcb5b9..2a94a133252c26 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowSessionsRuntime.test.ts @@ -28,6 +28,8 @@ import { DeferredPromise } from '../../../../../../base/common/async.js'; import { isEqual } from '../../../../../../base/common/resources.js'; import { ISessionOpenTelemetryService } from '../../../browser/sessionOpenTelemetryService.js'; import { buildTestSession } from '../../common/testSessionBuilder.js'; +import { constObservable } from '../../../../../../base/common/observable.js'; +import { ISession } from '../../../../../services/sessions/common/session.js'; suite('EditorWindowSessionsRuntime', () => { @@ -206,8 +208,8 @@ suite('EditorWindowSessionsService', () => { test('a later open supersedes an earlier one that still resolves its session, so the user\'s latest pick shows', async () => { const opened: string[] = []; const resolvingA = new DeferredPromise(); - const a = buildTestSession({ id: 'a', title: 'A' }).session; - const b = buildTestSession({ id: 'b', title: 'B' }).session; + const a: ISession = { ...buildTestSession({ id: 'a', title: 'A' }).session, loading: constObservable(false) }; + const b: ISession = { ...buildTestSession({ id: 'b', title: 'B' }).session, loading: constObservable(false) }; const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IChatViewHostService, upcastPartial({})); instantiationService.stub(ICommandService, upcastPartial({})); From b30c018a9fbab141a306d0a7a53e7ab602f85c5e Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 07:59:01 +0100 Subject: [PATCH 093/165] chat: the two-New-Chat race test releases the rig's unused chat Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/browser/widgetHosts/viewPane/chatViewPane.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index 61fbc314133734..581f1bc6d10d47 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -724,8 +724,10 @@ suite('ChatViewPane session transitions', () => { const takenLater = await second; await a.acquired.complete({ modelRef: a.ref }); const takenEarlier = await first; + // The rig's next chat, which these New Chats do not use + rig.nextRef.dispose(); - assert.deepStrictEqual({ takenEarlier, takenLater, bindings: rig.bindings.map(resource => resource?.path), composerCalls, disposedNew, shown: rig.widget.viewModel?.sessionResource.path }, { + assert.deepStrictEqual({ takenEarlier, takenLater, bindings: rig.bindings.map(resource => resource?.path), composerCalls, disposedNew, shown: rig.widget.viewModel?.sessionResource?.path }, { takenEarlier: true, takenLater: true, bindings: ['/untitled-b'], From 16821a6e374938eb6acd9bce3ef7aa412eae8b92 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 08:01:59 +0100 Subject: [PATCH 094/165] Worktree support: a working directory that changes back to where the folder is while a follow is under way is followed once that settles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostWorktree.contribution.ts | 11 ++++++----- .../agentHost/agentHostWorktree.test.ts | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts index 823462984356af..df59ccb3efb2e1 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts @@ -335,16 +335,17 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe if (!target || target.scheme !== Schemas.file || !this._enabled) { return; } + if (automatic && this._switching > 0) { + // Gives way to the switch under way, and follows where the chat works by then once that settles: also where that + // is where the folder is now, which the switch under way would leave + this._followWhenIdle = { shown, request: this._request }; + return; + } if (extUriBiasedIgnorePathCase.isEqual(target, folder.uri)) { // Where the folder is: the chat is the last one of its location also without a move this._rememberSession(folder.uri, shown.resource); return; } - if (automatic && this._switching > 0) { - // Gives way to the switch under way, and follows where the chat works by then once that settles - this._followWhenIdle = { shown, request: this._request }; - return; - } void this._switch(shown.resource, primary, target, automatic ? this._foreground : undefined); } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts index 5b5a2032049a13..fd213db9851e60 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts @@ -546,4 +546,23 @@ suite('AgentHostWorktree', () => { moves: ['/work/vscode.worktrees/feature', '/work/vscode', '/work/vscode.worktrees/created (automatic)'], }); }); + + test('a working directory that changes back to where the folder is while it follows the previous one is followed once that settles', async () => { + const setting = new DeferredPromise(); + const feature = createWorktree({ enabled: true, setting: setting.p }); + feature.show(agentSession('r')); + await settle(); + feature.workingDirectories.set(agentSession('r').toString(), worktree.fsPath); + feature.onDidChangeSessions.fire(); + await settle(); + // The move to the worktree is under way, as the session comes to work where the folder still is + feature.workingDirectories.set(agentSession('r').toString(), primary.fsPath); + feature.onDidChangeSessions.fire(); + await settle(); + setting.complete(); + await settle(); + await settle(); + + assert.deepStrictEqual(feature.applied, ['/work/vscode.worktrees/feature', '/work/vscode']); + }); }); From 6a53df9244a0154e0c5d60da012ed289c826742a Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 08:08:52 +0100 Subject: [PATCH 095/165] Chat part anchor: make room for the chat part beside a wide bar instead of overflowing the editor's column Shown, shown again, or moved beside its anchor, the chat part takes the width it lacks from the parts beyond the editor's column on its side, closest first, down to their minimum, such as the secondary side bar that holds the Chat view. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/workbench/browser/layout.ts | 57 ++++++++++++++++++++ src/vs/workbench/test/browser/layout.test.ts | 44 +++++++++++++-- 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index 4da7cd41e20f9d..32e46c144f1830 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -2478,8 +2478,12 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi // Focus inside the chat part that hides moves to the panel or the editor, not to the body of the window const hadFocus = hidden && this.hasFocus(Parts.CHAT_PART); + const shownWidth = hidden ? undefined : this.workbenchGrid.getViewCachedVisibleSize(this.chatPartView); this.chatPartHidden = hidden; this.workbenchGrid.setViewVisible(this.chatPartView, !hidden); + if (!hidden) { + this.ensureChatPartFits(shownWidth); + } // The sashes beside it move: where they meet the others changes too this.updateSashEndpoints(); if (hadFocus) { @@ -2506,7 +2510,9 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi if (this.chatPartView && this.chatPartHiddenByLayout) { this.chatPartHiddenByLayout = false; this.chatPartHidden = false; + const shownWidth = this.workbenchGrid.getViewCachedVisibleSize(this.chatPartView); this.workbenchGrid.setViewVisible(this.chatPartView, true); + this.ensureChatPartFits(shownWidth); this.updateSashEndpoints(); } } @@ -2572,6 +2578,56 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi return this.chatPartView && direction === this.getChatPartDirection() ? this.chatPartView : this.editorPartView; } + /** + * Makes room for a visible chat part where the editor's column is too narrow for the editor and the chat part at + * `width` (its own width by default), so that neither overflows the column: the parts beyond the column on the chat + * part's side, closest first, such as the secondary side bar that holds its anchor, give up width down to their + * minimum. The chat part then takes its width, leaving the editor its minimum. + */ + private ensureChatPartFits(width?: number): void { + const grid = this.workbenchGrid; + if (!this.chatPartView || this.chatPartHidden || !grid) { + return; + } + const columnWidth = () => grid.getViewSize(this.editorPartView).width + grid.getViewSize(this.chatPartView).width; + const target = Math.max(this.chatPartView.minimumWidth, width ?? grid.getViewSize(this.chatPartView).width); + let missing = this.editorPartView.minimumWidth + target - columnWidth(); + for (const neighbour of this.getChatPartSideNeighbours()) { + if (missing <= 0) { + break; + } + const size = grid.getViewSize(neighbour); + const given = Math.min(missing, size.width - neighbour.minimumWidth); + if (given > 0) { + grid.resizeView(neighbour, { width: size.width - given, height: size.height }); + missing = this.editorPartView.minimumWidth + target - columnWidth(); + } + } + const chatWidth = Math.min(target, columnWidth() - this.editorPartView.minimumWidth); + if (chatWidth !== grid.getViewSize(this.chatPartView).width) { + grid.resizeView(this.chatPartView, { width: chatWidth, height: grid.getViewSize(this.chatPartView).height }); + } + } + + /** The visible parts beyond the editor's column on the chat part's side, closest first. */ + private getChatPartSideNeighbours(): ISerializableView[] { + const side = this.getChatPartPosition(); + const sideBarSide = this.getSideBarPosition(); + const panelPosition = this.getPanelPosition(); + const neighbours: ISerializableView[] = []; + if (panelPosition === side && this.isVisible(Parts.PANEL_PART)) { + neighbours.push(this.panelPartView); + } + const auxiliaryBarSide = sideBarSide === Position.LEFT ? Position.RIGHT : Position.LEFT; + if (auxiliaryBarSide === side && this.isVisible(Parts.AUXILIARYBAR_PART)) { + neighbours.push(this.auxiliaryBarPartView); + } + if (sideBarSide === side && this.isVisible(Parts.SIDEBAR_PART)) { + neighbours.push(this.sideBarPartView); + } + return neighbours; + } + /** Moves the chat part immediately beside the editor, on the side it belongs on, at the size it has. */ private moveChatPartBesideEditor(): void { if (!this.chatPartView) { @@ -2590,6 +2646,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.workbenchGrid.moveView(this.chatPartView, sizing, this.editorPartView, this.getChatPartDirection()); if (width !== undefined) { this.workbenchGrid.resizeView(this.chatPartView, { width, height: this.workbenchGrid.getViewSize(this.chatPartView).height }); + this.ensureChatPartFits(width); } if (hadFocus) { if (isHTMLElement(focused) && focused.isConnected && this.chatPartView.element.contains(focused)) { diff --git a/src/vs/workbench/test/browser/layout.test.ts b/src/vs/workbench/test/browser/layout.test.ts index 426f37f419a0d0..51549a14bd5722 100644 --- a/src/vs/workbench/test/browser/layout.test.ts +++ b/src/vs/workbench/test/browser/layout.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { $, append } from '../../../base/browser/dom.js'; -import { Grid, Direction, ISerializedGrid, ISerializedNode, Orientation, SerializableGrid } from '../../../base/browser/ui/grid/grid.js'; +import { Grid, Direction, ISerializableView, ISerializedGrid, ISerializedNode, Orientation, SerializableGrid } from '../../../base/browser/ui/grid/grid.js'; import { mainWindow } from '../../../base/browser/window.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { toDisposable } from '../../../base/common/lifecycle.js'; @@ -151,6 +151,8 @@ suite('Workbench layout chat part', () => { readonly anchor?: ViewContainerLocation | null; /** Whether that view is registered, as it is by default; one that is not keeps a location saved for it. */ readonly anchorRegistered?: boolean; + /** The width of the window and of its bars, 1600, 250 and 260 by default. */ + readonly widths?: { readonly window?: number; readonly sideBar?: number; readonly auxiliaryBar?: number }; } /** The state a window left, to start another window from, as after a reload. */ @@ -197,7 +199,7 @@ suite('Workbench layout chat part', () => { ['panel.hidden', false], ['auxiliaryBar.hidden', false], ]); - const initialization = new Map([['sideBar.size', 250], ['auxiliaryBar.size', 260], ['panel.size', 200]]); + const initialization = new Map([['sideBar.size', configuration.widths?.sideBar ?? 250], ['auxiliaryBar.size', configuration.widths?.auxiliaryBar ?? 260], ['panel.size', 200]]); const anchor = { location: configuration.anchor ?? null, registered: configuration.anchorRegistered ?? true }; const viewEvents = { location: store.add(new Emitter()), container: store.add(new Emitter()), containerLocation: store.add(new Emitter()), containers: store.add(new Emitter()) }; let stored = saved ? saved.stored : configuration.chat && configuration.chat !== 'noLayout' ? JSON.stringify({ version: 1, ...configuration.chat }) : undefined; @@ -213,7 +215,7 @@ suite('Workbench layout chat part', () => { }(mainWindow.document.body)); Object.assign(layout, { ...parts, - _mainContainerDimension: { width: 1600, height: 900 }, + _mainContainerDimension: { width: configuration.widths?.window ?? 1600, height: 900 }, telemetryService: NullTelemetryService, // No view containers: the side bar and the panel show and hide empty paneCompositeService: { getActivePaneComposite: () => undefined, getLastActivePaneCompositeId: () => undefined }, @@ -321,7 +323,7 @@ suite('Workbench layout chat part', () => { const { layout, views, descriptor, save, anchor, focus, viewEvents } = createLayout(configuration, saved); const grid = store.add(SerializableGrid.deserialize(descriptor, { fromJSON: ({ type }: { type: Parts }) => views.get(type)! }, { proportionalLayout: false })); append(layout.mainContainer, grid.element); - grid.layout(1600, 900); + grid.layout(configuration.widths?.window ?? 1600, 900); Object.assign(layout, { workbenchGrid: grid }); const middle = () => shape((grid.serialize().root.data as ISerializedNode[])[2]); return { layout, grid, middle, save, anchor, views, focus, viewEvents }; @@ -600,4 +602,38 @@ suite('Workbench layout chat part', () => { defaultFocus: [], }); }); + + /** The widths across the window, of the parts the editor's row has, and whether those fit the window without overflowing. */ + function widthsAcross(layout: Layout, grid: SerializableGrid, views: Map, window: number) { + const width = (part: Parts) => layout.isVisible(part) ? grid.getViewSize(views.get(part)!).width : 0; + const chat = width(Parts.CHAT_PART); + const editor = width(Parts.EDITOR_PART); + const total = width(Parts.ACTIVITYBAR_PART) + width(Parts.SIDEBAR_PART) + editor + chat + width(Parts.AUXILIARYBAR_PART); + return { fits: total === window, chatAtLeastItsMinimum: chat >= 280, editorAtLeastItsMinimum: editor >= 220 }; + } + + test('the chat part shown beside a wide secondary side bar takes room from it, rather than overflow the editor\'s column', () => { + const { layout, grid, views } = createGridLayout({ sideBar: Position.LEFT, panel: Position.BOTTOM, alignment: 'center', chat: { visible: false, size: 400 }, anchor: ViewContainerLocation.AuxiliaryBar, widths: { window: 1200, auxiliaryBar: 590 } }); + const auxiliaryBarBefore = grid.getViewSize(views.get(Parts.AUXILIARYBAR_PART)!).width; + layout.setPartHidden(false, Parts.CHAT_PART); + + assert.deepStrictEqual({ ...widthsAcross(layout, grid, views, 1200), auxiliaryBarShrank: grid.getViewSize(views.get(Parts.AUXILIARYBAR_PART)!).width < auxiliaryBarBefore }, { + fits: true, chatAtLeastItsMinimum: true, editorAtLeastItsMinimum: true, auxiliaryBarShrank: true, + }); + }); + + test('the chat part shown beside a wide primary side bar takes room from it', () => { + const { layout, grid, views } = createGridLayout({ sideBar: Position.LEFT, panel: Position.BOTTOM, alignment: 'center', chat: { visible: false, size: 400 }, anchor: ViewContainerLocation.Sidebar, widths: { window: 1200, sideBar: 600 } }); + layout.setPartHidden(false, Parts.CHAT_PART); + + assert.deepStrictEqual(widthsAcross(layout, grid, views, 1200), { fits: true, chatAtLeastItsMinimum: true, editorAtLeastItsMinimum: true }); + }); + + test('the chat part that follows its anchor view to a wide secondary side bar takes room from it', () => { + const { layout, grid, views, anchor } = createGridLayout({ sideBar: Position.LEFT, panel: Position.BOTTOM, alignment: 'center', chat: { visible: true, size: 400 }, anchor: ViewContainerLocation.Sidebar, widths: { window: 1200, auxiliaryBar: 590 } }); + anchor.location = ViewContainerLocation.AuxiliaryBar; + (Reflect.get(layout, 'updateChatPartPosition') as () => void).call(layout); + + assert.deepStrictEqual(widthsAcross(layout, grid, views, 1200), { fits: true, chatAtLeastItsMinimum: true, editorAtLeastItsMinimum: true }); + }); }); From dd8a60671d819665392394a93cb317f01de58b97 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 08:10:24 +0100 Subject: [PATCH 096/165] Worktree infra: the session that a started chat moves to after its first request is shown as committed Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../viewPane/chatViewHostService.ts | 3 +- .../viewPane/chatViewHostService.test.ts | 2 +- .../widgetHosts/viewPane/chatViewPane.test.ts | 32 +++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts index 77362511cde31a..72c14993bd08d0 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts @@ -121,7 +121,8 @@ export class ChatViewPaneHost implements IChatViewHost { return { moved: async movedSessionResource => { const movedView = await this.viewsService.openView(ChatViewId) as ChatViewPane; - await movedView.loadSession(movedSessionResource); + // The session that the first request made: shown as committed, not as one the user opened + await movedView.loadSession(movedSessionResource, undefined, 'commit'); }, complete: async () => { transition.dispose(); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts index ae2c33f81522d2..5e640336b907ea 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts @@ -93,7 +93,7 @@ suite('ChatViewHostService', () => { `openView ${ChatViewId} focus=false`, 'loadSession test:/session reason=undefined commit', `setPartHidden false ${Parts.AUXILIARYBAR_PART}`, `openView ${ChatViewId} focus=undefined`, 'beforeOpen', 'suppress', 'progress', 'loadSession test:/session reason=explicitOverride', 'focus', - `openView ${ChatViewId} focus=undefined`, 'loadSession test:/moved reason=undefined', + `openView ${ChatViewId} focus=undefined`, 'loadSession test:/moved reason=undefined commit', 'unsuppress', 'progress done', `openView ${ChatViewId} focus=undefined`, 'startNewLocalSession', 'focus', ], diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index 8f48e6743a13a9..a2c6ce83f1f24b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -447,6 +447,38 @@ suite('ChatViewPane session transitions', () => { assert.deepStrictEqual({ stream, shown: rig.pane.shownSession.get()?.reason }, { stream: ['/chat open'], shown: 'open' }); }); + test('through the Chat view host, the session that a started chat moves to after its first request stays reported as committed', async () => { + const rig = createPane(); + rig.nextRef.dispose(); + const untitled = nextResource.with({ path: '/untitled-started' }); + const committed = nextResource.with({ path: '/committed' }); + const reference = (resource: URI) => Object.assign(toDisposable(() => { }), { object: upcastPartial({ sessionResource: resource }) }); + Object.assign(rig.pane, { chatService: { acquireOrLoadSession: async (resource: URI) => reference(resource) }, focus: () => { } }); + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IViewsService, upcastPartial({ + openView: (async () => rig.pane) as IViewsService['openView'], + onDidChangeViewVisibility: Event.None, + getViewWithId: (() => rig.pane as T) as IViewsService['getViewWithId'], + })); + instantiationService.stub(IViewDescriptorService, upcastPartial({})); + instantiationService.stub(IWorkbenchLayoutService, upcastPartial({})); + instantiationService.stub(IProgressService, upcastPartial({})); + const host = instantiationService.createInstance(ChatViewHostService); + const start = await host.startSession(untitled, { local: false, importingConversation: false }); + const stream: string[] = []; + store.add(autorun(reader => { + const shown = rig.pane.shownSession.read(reader); + stream.push(shown ? `${shown.resource.path} ${shown.reason}` : 'none'); + })); + stream.length = 0; + // The first request committed the session, which the host shows as committed, then the start moves to it + await host.openSession(committed, { preserveFocus: true, commit: true }); + await start.moved(committed); + start.dispose(); + + assert.deepStrictEqual(stream, ['/committed commit']); + }); + test('cancelling a replacement releases its pending reference before provider activation completes', async () => { const resolving = new DeferredPromise(); const rig = createPane(resolving.p); From 5aba8c47c3755a2181f0feb97f933fbb9740ce8d Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 08:11:34 +0100 Subject: [PATCH 097/165] Worktree support: dispose the Chat view host service in the moved-commit test, as #348309 makes it disposable Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index 6d090c7d6b4e37..1ddf55d900d0ea 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -601,7 +601,7 @@ suite('ChatViewPane session transitions', () => { instantiationService.stub(IViewDescriptorService, upcastPartial({})); instantiationService.stub(IWorkbenchLayoutService, upcastPartial({})); instantiationService.stub(IProgressService, upcastPartial({})); - const host = instantiationService.createInstance(ChatViewHostService); + const host = store.add(instantiationService.createInstance(ChatViewHostService)); const start = await host.startSession(untitled, { local: false, importingConversation: false }); const stream: string[] = []; store.add(autorun(reader => { From ded9a0a8bf930634532139a241e50c80c7f4d7b2 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 08:14:05 +0100 Subject: [PATCH 098/165] Chat part host: the session a started chat's first request made shows as committed As the Chat view shows it (#348304 dd8a60671d8), so a follow of where the window's folder is does not take it for a session the user opened. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentsView/agentsChatViewHost.ts | 3 ++- .../browser/agentsView/agentsChatViewHost.test.ts | 13 +++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts index ac5c4a44fb45bb..097b50fea125fc 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts @@ -182,7 +182,8 @@ export class AgentsChatViewHost implements IChatViewHost { return false; } const given = !isUntitledChatSession(sessionResource); - const shown = (sent || given || !isEqual(resource, sessionResource)) && !!await this.openSession(resource); + // The session its first request made shows as committed, as the Chat view shows it; a given session as opened + const shown = (sent || given || !isEqual(resource, sessionResource)) && !!await this.openSession(resource, given ? undefined : { commit: true }); if (!shown && generation !== this._generation) { // Deactivated while it looked the session up: no composer either return false; diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts index 485d881cc6186c..b0be70fb28a69c 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts @@ -50,7 +50,7 @@ suite('AgentsChatViewHost', () => { show: async (_target, showOptions) => { calls.push(`show preserveFocus=${showOptions?.preserveFocus}`); }, openComposer: async (target, openOptions) => { calls.push(`openComposer ${target?.providerId ? `provider=${target.providerId} ` : ''}sessionType=${target?.sessionTypeId} newComposer=${openOptions?.newComposer}`); }, getComposerWidget: async () => composerWidget, - openChat: async session => { calls.push(`openChat ${session.resource.toString()}`); }, + openChat: async (session, _chat, openOptions) => { calls.push(`openChat ${session.resource.toString()}${openOptions?.commit ? ' commit' : ''}`); }, isShowing: () => false, ...options.chatPart, }); @@ -142,7 +142,7 @@ suite('AgentsChatViewHost', () => { assert.deepStrictEqual({ results, calls }, { results: [undefined, undefined, false], calls: [] }); }); - test('a started session that runs shows in the chat part, its model held until the start ends', async () => { + test('a started session that runs shows in the chat part, its model held until the start ends: as committed when its first request made it, as opened when it was given', async () => { const untitled = URI.parse('agent-host-copilot:/untitled-1'); const real = URI.parse('agent-host-copilot:/session-1'); const { owner, widget } = sessionOf(real); @@ -152,10 +152,15 @@ suite('AgentsChatViewHost', () => { await start.moved(real); const shown = await start.complete(real, true); start.dispose(); + const made = calls.splice(0); + const given = await host.startSession(real, { local: false, importingConversation: false }); + await given.complete(real, true); + given.dispose(); - assert.deepStrictEqual({ shown, calls }, { + assert.deepStrictEqual({ shown, made, given: calls }, { shown: true, - calls: ['hold agent-host-copilot:/untitled-1', 'acquire', 'openChat agent-host-copilot:/session-1', 'release', 'release agent-host-copilot:/untitled-1'], + made: ['hold agent-host-copilot:/untitled-1', 'acquire', 'openChat agent-host-copilot:/session-1 commit', 'release', 'release agent-host-copilot:/untitled-1'], + given: ['hold agent-host-copilot:/session-1', 'acquire', 'openChat agent-host-copilot:/session-1', 'release', 'release agent-host-copilot:/session-1'], }); }); From f6f02953891b67d9a5800d815ff7c3d7336cb75c Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 08:23:31 +0100 Subject: [PATCH 099/165] Editor window composers: folder options follow the provider set later getComposerFolderOptions returns live options: a composer created before a feature offers its folders, such as the Chat view's composer during restore, follows that feature's folders and presentation, and the window's folders again once it goes. Also disposes the Chat view host service of #348304's committed-session test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../widgetHosts/viewPane/chatViewPane.test.ts | 2 +- .../editorWindowComposerFolders.ts | 35 ++++++++++++------- .../editorWindowComposerFolders.test.ts | 24 +++++++++++++ 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index c4c8d113de0bb3..2b2d5af0277657 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -605,7 +605,7 @@ suite('ChatViewPane session transitions', () => { instantiationService.stub(IViewDescriptorService, upcastPartial({})); instantiationService.stub(IWorkbenchLayoutService, upcastPartial({})); instantiationService.stub(IProgressService, upcastPartial({})); - const host = instantiationService.createInstance(ChatViewHostService); + const host = store.add(instantiationService.createInstance(ChatViewHostService)); const start = await host.startSession(untitled, { local: false, importingConversation: false }); const stream: string[] = []; store.add(autorun(reader => { diff --git a/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowComposerFolders.ts b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowComposerFolders.ts index 16b19e14cd9a05..e44748856609ef 100644 --- a/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowComposerFolders.ts +++ b/src/vs/workbench/contrib/sessions/browser/editorWindow/editorWindowComposerFolders.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { IObservable, observableFromEvent } from '../../../../../base/common/observable.js'; +import { derived, IObservable, observableFromEvent, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; @@ -28,7 +28,10 @@ export const IEditorWindowComposerFoldersService = createDecorator(); /** One per call of {@link setComposerFolderOptions}, also of the same provider. */ - private readonly _providers: { readonly provider: () => IEditorWindowComposerFolderOptions }[] = []; - private readonly _windowFolders: IObservable; + private readonly _providers = observableValue IEditorWindowComposerFolderOptions }[]>(this, []); + /** The options of the provider set last, if any. */ + private readonly _providedOptions: IObservable; + private readonly _options: IEditorWindowComposerFolderOptions; constructor(@IWorkspaceContextService workspaceContextService: IWorkspaceContextService) { - this._windowFolders = observableFromEvent(this, workspaceContextService.onDidChangeWorkspaceFolders, () => workspaceContextService.getWorkspace().folders.map(folder => folder.uri)); + const windowFolders = observableFromEvent(this, workspaceContextService.onDidChangeWorkspaceFolders, () => workspaceContextService.getWorkspace().folders.map(folder => folder.uri)); + this._providedOptions = derived(this, reader => this._providers.read(reader).at(-1)?.provider()); + this._options = { + // A provider without folders lets its composers create sessions anywhere, as a window without folders does + allowedFolders: derived(this, reader => { + const provided = this._providedOptions.read(reader); + return provided ? provided.allowedFolders?.read(reader) ?? [] : windowFolders.read(reader); + }), + getFolderPresentation: folderUri => this._providedOptions.get()?.getFolderPresentation?.(folderUri), + }; } getComposerFolderOptions(): IEditorWindowComposerFolderOptions { - return this._providers.at(-1)?.provider() ?? { allowedFolders: this._windowFolders }; + return this._options; } registerComposer(composer: IEditorWindowRetargetableComposer): IDisposable { @@ -77,12 +91,7 @@ export class EditorWindowComposerFoldersService implements IEditorWindowComposer setComposerFolderOptions(provider: () => IEditorWindowComposerFolderOptions): IDisposable { const registration = { provider }; - this._providers.push(registration); - return toDisposable(() => { - const index = this._providers.indexOf(registration); - if (index >= 0) { - this._providers.splice(index, 1); - } - }); + this._providers.set([...this._providers.get(), registration], undefined); + return toDisposable(() => this._providers.set(this._providers.get().filter(candidate => candidate !== registration), undefined)); } } diff --git a/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowComposerFolders.test.ts b/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowComposerFolders.test.ts index 74a8eb1b740a6f..4fef609fb5da12 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowComposerFolders.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/editorWindow/editorWindowComposerFolders.test.ts @@ -36,6 +36,30 @@ suite('EditorWindowComposerFoldersService', () => { assert.deepStrictEqual(steps, [['/repo'], ['/repo', '/repo.worktrees/a'], ['/repo', '/repo.worktrees/a'], ['/repo']]); }); + test('the options a composer took before a provider was set follow the provider, its presentation too, and the window\'s folders again once it goes', () => { + const service = new EditorWindowComposerFoldersService(upcastPartial({ + onDidChangeWorkspaceFolders: Event.None, + getWorkspace: () => upcastPartial({ folders: [upcastPartial({ uri: URI.file('/repo') })] }), + })); + // Taken once, as a composer created during restore takes them + const options = service.getComposerFolderOptions(); + const read = () => ({ folders: options.allowedFolders?.get().map(folder => folder.path), worktree: options.getFolderPresentation?.(URI.file('/repo.worktrees/a'))?.description }); + const before = read(); + + const provider = service.setComposerFolderOptions(() => ({ + allowedFolders: constObservable([URI.file('/repo'), URI.file('/repo.worktrees/a')]), + getFolderPresentation: folder => ({ label: folder.path, description: 'Worktree' }), + })); + const provided = read(); + provider.dispose(); + + assert.deepStrictEqual({ before, provided, after: read() }, { + before: { folders: ['/repo'], worktree: undefined }, + provided: { folders: ['/repo', '/repo.worktrees/a'], worktree: 'Worktree' }, + after: { folders: ['/repo'], worktree: undefined }, + }); + }); + test('retargets every registered composer, of the chat part and of the Chat view alike, until it is no longer registered', () => { const service = new EditorWindowComposerFoldersService(upcastPartial({ onDidChangeWorkspaceFolders: Event.None, getWorkspace: () => upcastPartial({ folders: [] }) })); const calls: string[] = []; From 9af30045f191e158a3ee8d360f856837f522e302 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 08:24:31 +0100 Subject: [PATCH 100/165] Chat part anchor: make room against the width the grid gives the editor's column, not the widths its parts claim at their minimum Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/workbench/browser/layout.ts | 33 ++++++++++++++++++-- src/vs/workbench/test/browser/layout.test.ts | 13 ++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index 32e46c144f1830..3a2f230ba05366 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -27,7 +27,7 @@ import { IHostService } from '../services/host/browser/host.js'; import { IBrowserWorkbenchEnvironmentService } from '../services/environment/browser/environmentService.js'; import { IEditorService } from '../services/editor/common/editorService.js'; import { EditorGroupLayout, GroupActivationReason, GroupOrientation, GroupsOrder, IEditorGroupsService } from '../services/editor/common/editorGroupsService.js'; -import { SerializableGrid, ISerializableView, ISerializedGrid, Orientation, ISerializedNode, ISerializedLeafNode, Direction, IViewSize, Sizing } from '../../base/browser/ui/grid/grid.js'; +import { SerializableGrid, ISerializableView, ISerializedGrid, Orientation, ISerializedNode, ISerializedLeafNode, Direction, IViewSize, Sizing, GridBranchNode, GridNode, isGridBranchNode } from '../../base/browser/ui/grid/grid.js'; import { Part, relayoutWorkbenchParts, updateSashEndpoints } from './part.js'; import { IStatusbarService } from '../services/statusbar/browser/statusbar.js'; import { IFileService } from '../../platform/files/common/files.js'; @@ -2589,7 +2589,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi if (!this.chatPartView || this.chatPartHidden || !grid) { return; } - const columnWidth = () => grid.getViewSize(this.editorPartView).width + grid.getViewSize(this.chatPartView).width; + const columnWidth = () => this.getChatPartColumnWidth(); const target = Math.max(this.chatPartView.minimumWidth, width ?? grid.getViewSize(this.chatPartView).width); let missing = this.editorPartView.minimumWidth + target - columnWidth(); for (const neighbour of this.getChatPartSideNeighbours()) { @@ -2609,6 +2609,35 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi } } + /** + * The width the grid gives the editor and the chat part together: of the branch that holds both, which their own widths + * exceed where they are at their minimum in too narrow a branch, or theirs where they are parts of the window's row. + */ + private getChatPartColumnWidth(): number { + const holdsBoth = (node: GridBranchNode) => node.children.some(child => !isGridBranchNode(child) && child.view === this.editorPartView) + && node.children.some(child => !isGridBranchNode(child) && child.view === this.chatPartView); + const find = (node: GridNode): GridBranchNode | undefined => { + if (!isGridBranchNode(node)) { + return undefined; + } + if (holdsBoth(node)) { + return node; + } + for (const child of node.children) { + const found = find(child); + if (found) { + return found; + } + } + return undefined; + }; + const branch = find(this.workbenchGrid.getViews()); + if (branch && branch !== this.workbenchGrid.getViews() && branch.children.length === 2) { + return branch.box.width; + } + return this.workbenchGrid.getViewSize(this.editorPartView).width + this.workbenchGrid.getViewSize(this.chatPartView!).width; + } + /** The visible parts beyond the editor's column on the chat part's side, closest first. */ private getChatPartSideNeighbours(): ISerializableView[] { const side = this.getChatPartPosition(); diff --git a/src/vs/workbench/test/browser/layout.test.ts b/src/vs/workbench/test/browser/layout.test.ts index 51549a14bd5722..d56e743816f028 100644 --- a/src/vs/workbench/test/browser/layout.test.ts +++ b/src/vs/workbench/test/browser/layout.test.ts @@ -636,4 +636,17 @@ suite('Workbench layout chat part', () => { assert.deepStrictEqual(widthsAcross(layout, grid, views, 1200), { fits: true, chatAtLeastItsMinimum: true, editorAtLeastItsMinimum: true }); }); + + test('the chat part shown at its minimum width beside a wide secondary side bar, whose column is narrower than the editor\'s and its minimum together, takes room from the bar', () => { + const shown = createGridLayout({ sideBar: Position.LEFT, panel: Position.BOTTOM, alignment: 'center', chat: { visible: false, size: 280 }, anchor: ViewContainerLocation.AuxiliaryBar, widths: { window: 1200, auxiliaryBar: 590 } }); + shown.layout.setPartHidden(false, Parts.CHAT_PART); + const moved = createGridLayout({ sideBar: Position.LEFT, panel: Position.BOTTOM, alignment: 'center', chat: { visible: true, size: 280 }, anchor: ViewContainerLocation.Sidebar, widths: { window: 1200, auxiliaryBar: 590 } }); + moved.anchor.location = ViewContainerLocation.AuxiliaryBar; + (Reflect.get(moved.layout, 'updateChatPartPosition') as () => void).call(moved.layout); + + assert.deepStrictEqual({ shown: widthsAcross(shown.layout, shown.grid, shown.views, 1200), moved: widthsAcross(moved.layout, moved.grid, moved.views, 1200) }, { + shown: { fits: true, chatAtLeastItsMinimum: true, editorAtLeastItsMinimum: true }, + moved: { fits: true, chatAtLeastItsMinimum: true, editorAtLeastItsMinimum: true }, + }); + }); }); From 16759fb5eed81540e568cf4f122a1113f1fb7fc0 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 08:25:50 +0100 Subject: [PATCH 101/165] chat: dispose the Chat view host service of the moved-session test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index c4c8d113de0bb3..2b2d5af0277657 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -605,7 +605,7 @@ suite('ChatViewPane session transitions', () => { instantiationService.stub(IViewDescriptorService, upcastPartial({})); instantiationService.stub(IWorkbenchLayoutService, upcastPartial({})); instantiationService.stub(IProgressService, upcastPartial({})); - const host = instantiationService.createInstance(ChatViewHostService); + const host = store.add(instantiationService.createInstance(ChatViewHostService)); const start = await host.startSession(untitled, { local: false, importingConversation: false }); const stream: string[] = []; store.add(autorun(reader => { From 8482c9a092b611f02f96f43617a6aca639dcad3a Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 08:31:03 +0100 Subject: [PATCH 102/165] Chat window composer: a retarget aims the picker before its observers run Set the target and fire onDidRetarget in one transaction, so an observer of the allowed folders, such as the picker's fallback, sees the new folder only once the picker shows it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/chatEditor/chatWindowComposer.ts | 10 +++++--- .../chatEditor/chatWindowComposer.test.ts | 23 ++++++++++++++++++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts b/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts index af1c202015f7ea..d972420e91ef88 100644 --- a/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts +++ b/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts @@ -7,7 +7,7 @@ import { isCancellationError } from '../../../../../base/common/errors.js'; import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { derived, IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { derived, IObservable, observableValue, transaction } from '../../../../../base/common/observable.js'; import { parse, stringify } from '../../../../../base/common/marshalling.js'; import { extUriBiasedIgnorePathCase } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -318,10 +318,14 @@ export class ChatWindowComposerModel extends Disposable implements INewSessionDr } const retargeted = { ...target, folderUri }; this._hostFolder = folderUri; - this._requestedTarget.set(retargeted, undefined); // As a target the user picks: a background send that fails then offers its prompt back, not into another folder silently this._targetGeneration++; - this._onDidRetarget.fire(retargeted); + // Observers of the target and of the allowed folders, such as the picker's, run only once those of the retarget aimed + // the picker and the draft at the new folder: not while the picker still shows a folder no longer allowed + transaction(tx => { + this._requestedTarget.set(retargeted, tx); + this._onDidRetarget.fire(retargeted); + }); return true; } diff --git a/src/vs/workbench/contrib/sessions/test/browser/chatEditor/chatWindowComposer.test.ts b/src/vs/workbench/contrib/sessions/test/browser/chatEditor/chatWindowComposer.test.ts index e16020368646e7..dbf7be4611e985 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/chatEditor/chatWindowComposer.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/chatEditor/chatWindowComposer.test.ts @@ -8,7 +8,7 @@ import { DeferredPromise } from '../../../../../../base/common/async.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { DisposableStore, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; -import { constObservable, IObservable, ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { autorun, constObservable, IObservable, ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -550,6 +550,27 @@ suite('Chat window composer', () => { assert.deepStrictEqual({ before, after: allowed() }, { before: ['/repo'], after: ['/repo', '/repo.worktrees/a'] }); }); + test('a composer retargeted between two folders outside the window\'s folders keeps the new folder: an observer of its allowed folders, as its picker, sees it only once the picker shows it', () => { + const store = disposables.add(new DisposableStore()); + const { instantiationService } = createServices(store); + const composers = store.add(instantiationService.createInstance(ChatWindowComposers, async () => undefined)); + const composer = store.add(composers.create(URI.parse('vscode-agents-composer:/between'), { target: { folderUri: URI.file('/repo.worktrees/a') }, allowedFolders: constObservable([URI.file('/repo')]) }, async () => { })); + // The picker: shows what onDidRetarget aims it at, and falls back to an allowed folder when what it shows is no longer allowed + let shown = '/repo.worktrees/a'; + store.add(composer.onDidRetarget(target => shown = target.folderUri!.path)); + store.add(autorun(reader => { + const allowed = composer.allowedFolders!.read(reader).map(folder => folder.path); + if (!allowed.includes(shown)) { + shown = allowed[0]; + composer.setRequestedTarget({ folderUri: URI.file(shown) }); + } + })); + + composer.retargetFolder(URI.file('/repo.worktrees/b'), URI.file('/repo.worktrees/a')); + + assert.deepStrictEqual({ shown, aimsAt: composer.requestedTarget.get()?.folderUri?.path }, { shown: '/repo.worktrees/b', aimsAt: '/repo.worktrees/b' }); + }); + test('a composer does not follow its host to another folder while it sends: from the user\'s submit, before its request is accepted, until the request ends', () => { const store = disposables.add(new DisposableStore()); const { onDidAccept, onDidEnd, slots, createComposer } = createServices(store); From 6ed0b73b404ab41588ebd38a2ad963d38bd589c3 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 08:32:26 +0100 Subject: [PATCH 103/165] chat: a New Chat that an open supersedes does not claim its host The Chat view host service counts the claims that something asks for, such as an open, and a New Chat claims its host only if none came while it waited for the host: an open that completes meanwhile stays the shown chat. The pane still reports a superseded New Chat as handled, so nothing falls back to another New Chat. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../viewPane/chatViewHostService.ts | 19 +++++++-- .../widgetHosts/viewPane/chatViewPane.test.ts | 41 +++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts index a9dca4cb06a40b..a11b5027a0b7e1 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts @@ -179,7 +179,7 @@ export class ChatViewHostService extends Disposable implements IChatViewHostServ private trackShownSession(host: IChatViewHost): IDisposable { return host.shownSession ? runOnChange(host.shownSession, shown => { if (shown) { - this.claim(host); + this.claim(host, false, false); } }) : Disposable.None; } @@ -187,8 +187,17 @@ export class ChatViewHostService extends Disposable implements IChatViewHostServ private readonly _replaced = observableValue(this, false); readonly replaced: IObservable = this._replaced; - /** Makes `host` the host that showed something last: a new chat of New Chat while `newChat`. */ - private claim(host: IChatViewHost, newChat = false): void { + /** Counts the claims that something asked for, such as an open, as opposed to a host showing a chat on its own. */ + private requestedClaims = 0; + + /** + * Makes `host` the host that showed something last: a new chat of New Chat while `newChat`. A claim that something asked + * for (`requested`) supersedes a New Chat under way, which then no longer claims its host. + */ + private claim(host: IChatViewHost, newChat = false, requested = true): void { + if (requested) { + this.requestedClaims++; + } // A host no longer registered, such as one that completes a reveal after it was removed, shows nothing of the Chat view's if (host !== this.chatViewHost && !this.registrations.some(registration => registration.host === host)) { return; @@ -281,13 +290,15 @@ export class ChatViewHostService extends Disposable implements IChatViewHostServ } async newChat(target?: IChatViewNewChatTarget, options?: IChatViewHostNewChatOptions): Promise { + // An open or other request that completes meanwhile shows what it asked for: the New Chat, superseded, claims nothing + const requestedClaims = this.requestedClaims; let host = this.host; let taken = await host.newChat?.(target, options) ?? false; if (!taken && options?.ownChat && host !== this.chatViewHost) { host = this.chatViewHost; taken = await host.newChat?.(target, options) ?? false; } - if (taken) { + if (taken && requestedClaims === this.requestedClaims) { this.claim(host, true); } return taken; diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index 2b2d5af0277657..02f173a3de0236 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -549,6 +549,47 @@ suite('ChatViewPane session transitions', () => { }); }); + test('through the Chat view host, a New Chat of its own that an open supersedes leaves the opened chat as the shown one', async () => { + const rig = createPane(); + rig.nextRef.dispose(); + const opened = nextResource.with({ path: '/opened' }); + const reference = (resource: URI) => Object.assign(toDisposable(() => { }), { object: upcastPartial({ sessionResource: resource }) }); + const newChatAcquired = new DeferredPromise<{ modelRef: IChatModelReference }>(); + Object.assign(rig.pane, { + chatService: { acquireOrLoadSession: async (resource: URI) => reference(resource) }, + acquireDefaultNewSession: () => newChatAcquired.p, + updateViewState: () => { }, + providedComposer: { newChat: () => { }, focus: () => { } }, + composerShown: false, + }); + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IViewsService, upcastPartial({ + openView: (async () => rig.pane) as IViewsService['openView'], + onDidChangeViewVisibility: Event.None, + getViewWithId: (() => rig.pane as T) as IViewsService['getViewWithId'], + isViewVisible: () => true, + })); + instantiationService.stub(IViewDescriptorService, upcastPartial({})); + instantiationService.stub(IWorkbenchLayoutService, upcastPartial({})); + instantiationService.stub(IProgressService, upcastPartial({})); + const host = store.add(instantiationService.createInstance(ChatViewHostService)); + const shown = () => { const value = host.shownSession.get(); return value ? `${value.resource.path} ${value.reason}` : 'none'; }; + + // New Chat of its own waits for its new chat, and meanwhile the user opens a chat + const resetting = host.newChat(undefined, { ownChat: true, preserveFocus: true }); + await host.openSession(opened, { preserveFocus: true }); + const afterOpen = shown(); + await newChatAcquired.complete({ modelRef: reference(nextResource.with({ path: '/untitled-reset' })) }); + const taken = await resetting; + + assert.deepStrictEqual({ afterOpen, taken, afterReset: shown(), widget: rig.widget.viewModel?.sessionResource.path }, { + afterOpen: '/opened open', + taken: true, + afterReset: '/opened open', + widget: '/opened', + }); + }); + test('through the Chat view host, an open of the chat it shows already that supersedes a restore of it under way reports the open, not the restore', async () => { const rig = createPane(); rig.nextRef.dispose(); From 6d5fd1be47f16e94dd605554bd5a7d43324ad734 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 08:34:25 +0100 Subject: [PATCH 104/165] Chat part test: an open to restore the chat it shows already reports it as restored Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessions/test/browser/agentsView/agentsChatPart.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts index 03d8fcacb1f648..e581d1f8e6c204 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts @@ -367,8 +367,11 @@ suite('AgentsChatPartService', () => { await service.openChat(restoredSession.session, main, { commit: true }); const committed = await shown(); await service.openChat(restoredSession.session, main); + const opened = await shown(); + // The chat it shows already, opened to restore, such as where the user moved the window's folder: restored, not opened + await service.openChat(restoredSession.session, main, { restore: true }); - assert.deepStrictEqual({ restored, committed, opened: await shown() }, { restored: 'main restore', committed: 'main commit', opened: 'main open' }); + assert.deepStrictEqual({ restored, committed, opened, restoredAgain: await shown() }, { restored: 'main restore', committed: 'main commit', opened: 'main open', restoredAgain: 'main restore' }); }); test('a tab recalled as restored that activates before its open completes shows as restored, never as opened in between', async () => { From 5dc6d4d29de8f4563e6f093b9136b37f78c6a3dc Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 08:39:01 +0100 Subject: [PATCH 105/165] Worktree support: the session layer's source follows every session the providers know, not only the listed ones Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostWorktreeComposerFolders.ts | 5 ++- .../agentHostWorktreeComposerFolders.test.ts | 44 +++++++++++++++++-- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts index d6d606d763abc0..fa6d542b8df437 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts @@ -162,8 +162,9 @@ export class AgentHostWorktreeSessionLayerSource extends Disposable implements I @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, ) { super(); - // Also as a session comes to work elsewhere, such as in the worktree its first request created - const sessionsChanged = observableSignalFromEvent(this, _sessionsManagementService.onDidChangeSessions); + // As any session it knows is added, removed or changes, also one outside the listed ones (getAllSessions), and as a + // session comes to work elsewhere, such as in the worktree its first request created + const sessionsChanged = observableSignalFromEvent(this, _sessionsManagementService.onDidChangeSession); let first = true; this._register(autorun(reader => { sessionsChanged.read(reader); diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts index da13335aa01e6c..cb7a5653dbdfa3 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts @@ -221,7 +221,7 @@ suite('AgentHostWorktreeComposerFolders', () => { instantiationService.stub(IContextKeyService, contextKeyService); instantiationService.stub(IWorkbenchEnvironmentService, upcastPartial({ isSessionsWindow: false })); instantiationService.stub(IAgentHostWorktreeSessionSources, store.add(new AgentHostWorktreeSessionSources())); - instantiationService.stub(ISessionsManagementService, upcastPartial({ onDidChangeSessions: Event.None, getAllSessions: () => [], getSessionForChatResource: () => undefined })); + instantiationService.stub(ISessionsManagementService, upcastPartial({ onDidChangeSession: Event.None, getAllSessions: () => [], getSessionForChatResource: () => undefined })); // No chat part: the service of the window's composers only instantiationService.stub(IEditorWindowComposerFoldersService, upcastPartial({ setComposerFolderOptions: provider => { calls.push('options'); options = provider; return toDisposable(() => { calls.push('options cleared'); options = undefined; }); }, @@ -264,7 +264,7 @@ suite('AgentHostWorktreeComposerFolders', () => { instantiationService.stub(IContextKeyService, contextKeyService); instantiationService.stub(IWorkbenchEnvironmentService, upcastPartial({ isSessionsWindow: false })); instantiationService.stub(IAgentHostWorktreeSessionSources, store.add(new AgentHostWorktreeSessionSources())); - instantiationService.stub(ISessionsManagementService, upcastPartial({ onDidChangeSessions: Event.None, getAllSessions: () => [], getSessionForChatResource: () => undefined })); + instantiationService.stub(ISessionsManagementService, upcastPartial({ onDidChangeSession: Event.None, getAllSessions: () => [], getSessionForChatResource: () => undefined })); const service = instantiationService.createInstance(EditorWindowComposerFoldersService); instantiationService.stub(IEditorWindowComposerFoldersService, service); const retargeted: string[] = []; @@ -293,10 +293,10 @@ suite('AgentHostWorktreeComposerFolders', () => { session(LOCAL_AGENT_HOST_PROVIDER_ID, '/work/vscode.worktrees/archived', [chat('/archived')], true), session('remote-agent-host', '/work/vscode.worktrees/remote', [chat('/remote')]), ]; - const onDidChangeSessions = store.add(new Emitter()); + const onDidChangeSession = store.add(new Emitter()); const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(ISessionsManagementService, upcastPartial({ - onDidChangeSessions: onDidChangeSessions.event, + onDidChangeSession: onDidChangeSession.event, getAllSessions: () => sessions, getSessionForChatResource: (resource: URI) => { for (const candidate of sessions) { @@ -322,4 +322,40 @@ suite('AgentHostWorktreeComposerFolders', () => { changes: 1, }); }); + + test('the session layer\'s source learns of a session outside the listed ones, such as one of another folder, as the providers report it, and of where its chats work', () => { + const workspace = (path: string) => upcastPartial({ folders: [{ root: URI.file('/work/vscode'), workingDirectory: URI.file(path), name: 'vscode', description: undefined }] }); + const chatWorkspace = observableValue('workspace', undefined); + const chat = upcastPartial({ resource: URI.from({ scheme: 'ahp-chat', path: '/hidden' }), workspace: chatWorkspace }); + const hidden = upcastPartial({ + providerId: LOCAL_AGENT_HOST_PROVIDER_ID, + isArchived: observableValue('archived', false), + workspace: observableValue('workspace', workspace('/work/vscode')), + chats: observableValue('chats', [chat]), + }); + const sessions: ISession[] = []; + // Only every session's changes, not the listed ones', report it + const onDidChangeSession = store.add(new Emitter()); + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(ISessionsManagementService, upcastPartial({ + onDidChangeSessions: Event.None, + onDidChangeSession: onDidChangeSession.event, + getAllSessions: () => sessions, + getSessionForChatResource: (resource: URI) => sessions.length && resource.toString() === chat.resource.toString() ? { session: hidden, chat } : undefined, + })); + const source = store.add(instantiationService.createInstance(AgentHostWorktreeSessionLayerSource)); + let changes = 0; + store.add(source.onDidChange(() => changes++)); + sessions.push(hidden); + onDidChangeSession.fire(undefined as never); + const added = changes; + chatWorkspace.set(workspace('/work/vscode.worktrees/hidden'), undefined); + + assert.deepStrictEqual({ added, changes, workingDirectory: source.getWorkingDirectory(chat.resource)?.path, all: source.getWorkingDirectories().map(uri => uri.path) }, { + added: 1, + changes: 2, + workingDirectory: '/work/vscode.worktrees/hidden', + all: ['/work/vscode', '/work/vscode.worktrees/hidden'], + }); + }); }); From 9b07a590d376f3e168d56ced3ed9522e7e01fe98 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 08:46:23 +0100 Subject: [PATCH 106/165] chat: the latest New Chat claims its host; a removed host's claim supersedes nothing - A New Chat takes a generation as it starts and claims its host only if it is still the latest New Chat and no open or other request claimed a host since: of two under way, the later one is the shown none, also as the earlier completes first. - A claim of a host no longer registered is rejected before it counts, so a late reveal of a removed host does not supersede a New Chat under way. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../viewPane/chatViewHostService.ts | 17 ++++--- .../viewPane/chatViewHostService.test.ts | 25 +++++++++++ .../widgetHosts/viewPane/chatViewPane.test.ts | 45 +++++++++++++++++++ 3 files changed, 81 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts index a11b5027a0b7e1..4746f827cf58de 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewHostService.ts @@ -189,19 +189,22 @@ export class ChatViewHostService extends Disposable implements IChatViewHostServ /** Counts the claims that something asked for, such as an open, as opposed to a host showing a chat on its own. */ private requestedClaims = 0; + /** Counts the New Chats started: the one started last supersedes those still under way. */ + private newChats = 0; /** * Makes `host` the host that showed something last: a new chat of New Chat while `newChat`. A claim that something asked * for (`requested`) supersedes a New Chat under way, which then no longer claims its host. */ private claim(host: IChatViewHost, newChat = false, requested = true): void { - if (requested) { - this.requestedClaims++; - } - // A host no longer registered, such as one that completes a reveal after it was removed, shows nothing of the Chat view's + // A host no longer registered, such as one that completes a reveal after it was removed, shows nothing of the Chat view's, + // and supersedes nothing if (host !== this.chatViewHost && !this.registrations.some(registration => registration.host === host)) { return; } + if (requested && !newChat) { + this.requestedClaims++; + } this.latest.set({ host, newChat }, undefined); this.updateChatViewTracking(); } @@ -290,7 +293,9 @@ export class ChatViewHostService extends Disposable implements IChatViewHostServ } async newChat(target?: IChatViewNewChatTarget, options?: IChatViewHostNewChatOptions): Promise { - // An open or other request that completes meanwhile shows what it asked for: the New Chat, superseded, claims nothing + // An open or other request that completes meanwhile shows what it asked for, and a later New Chat its new chat: the New + // Chat, superseded, claims nothing + const newChat = ++this.newChats; const requestedClaims = this.requestedClaims; let host = this.host; let taken = await host.newChat?.(target, options) ?? false; @@ -298,7 +303,7 @@ export class ChatViewHostService extends Disposable implements IChatViewHostServ host = this.chatViewHost; taken = await host.newChat?.(target, options) ?? false; } - if (taken && requestedClaims === this.requestedClaims) { + if (taken && newChat === this.newChats && requestedClaims === this.requestedClaims) { this.claim(host, true); } return taken; diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts index 1863e970ec334e..5feb9fa0369853 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewHostService.test.ts @@ -356,4 +356,29 @@ suite('ChatViewHostService', () => { assert.deepStrictEqual({ shown: service.shownSession.get()?.resource.path, replaced: service.replaced.get() }, { shown: '/chat-view', replaced: false }); }); + + test('a host that completes a reveal after it is removed does not supersede a New Chat under way', async () => { + const { service, shownSession, showView } = createService(); + shownSession.set({ resource: URI.parse('vscode-chat-session://local/chat-view'), reason: 'open' }, undefined); + showView(); + const revealed = new DeferredPromise(); + const removed = service.registerHost({ ...upcastPartial({}), reveal: () => revealed.p }); + const revealing = service.reveal(); + removed.dispose(); + const newChatShown = new DeferredPromise(); + const registration = service.registerHost({ + ...upcastPartial({}), + shownSession: constObservable({ resource: URI.parse('agent-host-copilot:/before'), reason: 'open' as const }), + newChat: () => newChatShown.p, + }); + const newChat = service.newChat(); + await revealed.complete(undefined); + await revealing; + await newChatShown.complete(true); + const taken = await newChat; + const shown = service.shownSession.get(); + registration.dispose(); + + assert.deepStrictEqual({ taken, shown }, { taken: true, shown: undefined }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts index 02f173a3de0236..b0eba5eeacdec0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatViewPane.test.ts @@ -590,6 +590,51 @@ suite('ChatViewPane session transitions', () => { }); }); + test('through the Chat view host, of two New Chats of its own under way, the later one is the shown none, also as the earlier one completes first, until its first send commits', async () => { + const rig = createPane(); + rig.nextRef.dispose(); + const reference = (resource: URI) => Object.assign(toDisposable(() => { }), { object: upcastPartial({ sessionResource: resource }) }); + const acquisitions = [new DeferredPromise<{ modelRef: IChatModelReference }>(), new DeferredPromise<{ modelRef: IChatModelReference }>()]; + const [acquiredA, acquiredB] = acquisitions; + const pending = [...acquisitions]; + Object.assign(rig.pane, { + chatService: { acquireOrLoadSession: async (resource: URI) => reference(resource) }, + acquireDefaultNewSession: () => pending.shift()!.p, + updateViewState: () => { }, + providedComposer: { newChat: () => { }, focus: () => { } }, + composerShown: false, + }); + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IViewsService, upcastPartial({ + openView: (async () => rig.pane) as IViewsService['openView'], + onDidChangeViewVisibility: Event.None, + getViewWithId: (() => rig.pane as T) as IViewsService['getViewWithId'], + isViewVisible: () => true, + })); + instantiationService.stub(IViewDescriptorService, upcastPartial({})); + instantiationService.stub(IWorkbenchLayoutService, upcastPartial({})); + instantiationService.stub(IProgressService, upcastPartial({})); + const host = store.add(instantiationService.createInstance(ChatViewHostService)); + const shown = () => { const value = host.shownSession.get(); return value ? `${value.resource.path} ${value.reason}` : 'none'; }; + + const newChatA = host.newChat(undefined, { ownChat: true, preserveFocus: true }); + const newChatB = host.newChat(undefined, { ownChat: true, preserveFocus: true }); + await acquiredA.complete({ modelRef: reference(nextResource.with({ path: '/untitled-a' })) }); + await newChatA; + await acquiredB.complete({ modelRef: reference(nextResource.with({ path: '/untitled-b' })) }); + await newChatB; + const afterNewChats = shown(); + const widget = rig.widget.viewModel?.sessionResource.path; + // Its first send commits the session it starts + await host.openSession(nextResource.with({ path: '/committed' }), { preserveFocus: true, commit: true }); + + assert.deepStrictEqual({ afterNewChats, widget, afterCommit: shown() }, { + afterNewChats: 'none', + widget: '/untitled-b', + afterCommit: '/committed commit', + }); + }); + test('through the Chat view host, an open of the chat it shows already that supersedes a restore of it under way reports the open, not the restore', async () => { const rig = createPane(); rig.nextRef.dispose(); From fd658d5c4885984132daf287575386acef59a3c9 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 08:47:56 +0100 Subject: [PATCH 107/165] Chat part anchor: budget the chat part against its branch's width less its other children, such as the side bar beside it when the panel is aligned to its side Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/workbench/browser/layout.ts | 17 +++++++++-------- src/vs/workbench/test/browser/layout.test.ts | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index 3a2f230ba05366..41b88cab593ed7 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -2610,17 +2610,17 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi } /** - * The width the grid gives the editor and the chat part together: of the branch that holds both, which their own widths - * exceed where they are at their minimum in too narrow a branch, or theirs where they are parts of the window's row. + * The width the grid gives the editor and the chat part together: that of the branch that holds both, less that of its + * other children, such as the primary side bar beside them when the panel is aligned to its side. Their own widths + * exceed it where they are at their minimum in too narrow a branch. */ private getChatPartColumnWidth(): number { - const holdsBoth = (node: GridBranchNode) => node.children.some(child => !isGridBranchNode(child) && child.view === this.editorPartView) - && node.children.some(child => !isGridBranchNode(child) && child.view === this.chatPartView); + const isEditorOrChat = (node: GridNode) => !isGridBranchNode(node) && (node.view === this.editorPartView || node.view === this.chatPartView); const find = (node: GridNode): GridBranchNode | undefined => { if (!isGridBranchNode(node)) { return undefined; } - if (holdsBoth(node)) { + if (node.children.filter(isEditorOrChat).length === 2) { return node; } for (const child of node.children) { @@ -2632,10 +2632,11 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi return undefined; }; const branch = find(this.workbenchGrid.getViews()); - if (branch && branch !== this.workbenchGrid.getViews() && branch.children.length === 2) { - return branch.box.width; + if (!branch) { + return this.workbenchGrid.getViewSize(this.editorPartView).width + this.workbenchGrid.getViewSize(this.chatPartView!).width; } - return this.workbenchGrid.getViewSize(this.editorPartView).width + this.workbenchGrid.getViewSize(this.chatPartView!).width; + const others = branch.children.filter(child => !isEditorOrChat(child)).reduce((width, child) => width + child.box.width, 0); + return branch.box.width - others; } /** The visible parts beyond the editor's column on the chat part's side, closest first. */ diff --git a/src/vs/workbench/test/browser/layout.test.ts b/src/vs/workbench/test/browser/layout.test.ts index d56e743816f028..a8b8e120f3c6d1 100644 --- a/src/vs/workbench/test/browser/layout.test.ts +++ b/src/vs/workbench/test/browser/layout.test.ts @@ -649,4 +649,20 @@ suite('Workbench layout chat part', () => { moved: { fits: true, chatAtLeastItsMinimum: true, editorAtLeastItsMinimum: true }, }); }); + + for (const alignment of ['center', 'left', 'right'] satisfies PanelAlignment[]) { + for (const sideBar of [Position.LEFT, Position.RIGHT]) { + test(`the chat part at its minimum width fits beside a wide secondary side bar as it shows or follows its anchor there, panel aligned ${alignment}, side bar ${sideBar === Position.LEFT ? 'left' : 'right'}`, () => { + const widths = { window: 1200, auxiliaryBar: 590 }; + const shown = createGridLayout({ sideBar, panel: Position.BOTTOM, alignment, chat: { visible: false, size: 280 }, anchor: ViewContainerLocation.AuxiliaryBar, widths }); + shown.layout.setPartHidden(false, Parts.CHAT_PART); + const moved = createGridLayout({ sideBar, panel: Position.BOTTOM, alignment, chat: { visible: true, size: 280 }, anchor: ViewContainerLocation.Sidebar, widths }); + moved.anchor.location = ViewContainerLocation.AuxiliaryBar; + (Reflect.get(moved.layout, 'updateChatPartPosition') as () => void).call(moved.layout); + + const fit = { fits: true, chatAtLeastItsMinimum: true, editorAtLeastItsMinimum: true }; + assert.deepStrictEqual({ shown: widthsAcross(shown.layout, shown.grid, shown.views, 1200), moved: widthsAcross(moved.layout, moved.grid, moved.views, 1200) }, { shown: fit, moved: fit }); + }); + } + } }); From 90eb147f2ec32cfbba75c51f447760f2e6052cda Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 08:48:30 +0100 Subject: [PATCH 108/165] Chat window composer: no retarget while a submit runs its checks The input snapshot marks a submit as submitting from before its checks and waits (session readiness, the host's confirmation, a submit handler) until it ends, also as it is cancelled or fails; retargetFolder declines meanwhile. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/chat/sharedChatInputComposer.ts | 10 ++++++++++ .../browser/chatEditor/chatWindowComposer.ts | 2 +- .../sessions/common/newChatDraftState.ts | 18 ++++++++++++++++++ .../chat/sharedChatInputComposer.test.ts | 14 ++++++++++++++ .../chatEditor/chatWindowComposer.test.ts | 9 +++++++-- 5 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/sessions/browser/chat/sharedChatInputComposer.ts b/src/vs/workbench/contrib/sessions/browser/chat/sharedChatInputComposer.ts index 332fe35e8bf569..daf60a8f4efd73 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/sharedChatInputComposer.ts +++ b/src/vs/workbench/contrib/sessions/browser/chat/sharedChatInputComposer.ts @@ -1078,7 +1078,17 @@ export class SharedChatInputComposer extends Disposable implements INewSessionCo } } + /** Sends the input, marked as submitting from here on, before any check or wait, so its host doesn't retarget it meanwhile. */ private async _send(rawQuery: string, attachments: readonly IChatRequestVariableEntry[], background: boolean): Promise { + const submitting = this.options.inputSnapshot?.beginSubmit(); + try { + return await this._doSend(rawQuery, attachments, background); + } finally { + submitting?.dispose(); + } + } + + private async _doSend(rawQuery: string, attachments: readonly IChatRequestVariableEntry[], background: boolean): Promise { const widget = this._widget; const query = rawQuery.trim(); const hasAdditionalSendContent = this.options.hasAdditionalSendContent?.get() ?? false; diff --git a/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts b/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts index d972420e91ef88..e73ad523ddc9cf 100644 --- a/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts +++ b/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts @@ -312,7 +312,7 @@ export class ChatWindowComposerModel extends Disposable implements INewSessionDr retargetFolder(folderUri: URI, from?: URI): boolean { const target = this._requestedTarget.get(); const current = target?.folderUri; - if (this.input.get().attempt || this._sending.get() || !target || !current || !this._hostFolder || !extUriBiasedIgnorePathCase.isEqual(current, this._hostFolder) + if (this.input.isSubmitting || this.input.get().attempt || this._sending.get() || !target || !current || !this._hostFolder || !extUriBiasedIgnorePathCase.isEqual(current, this._hostFolder) || (from && !extUriBiasedIgnorePathCase.isEqual(current, from)) || extUriBiasedIgnorePathCase.isEqual(current, folderUri)) { return false; } diff --git a/src/vs/workbench/contrib/sessions/common/newChatDraftState.ts b/src/vs/workbench/contrib/sessions/common/newChatDraftState.ts index cbe775548af2d6..7e47fbdd296bf8 100644 --- a/src/vs/workbench/contrib/sessions/common/newChatDraftState.ts +++ b/src/vs/workbench/contrib/sessions/common/newChatDraftState.ts @@ -75,6 +75,14 @@ export interface IComposerInputSnapshot { beginSendAttempt(input: IChatDraft): string; /** Ends the attempt of `id`: its send was rejected, failed, was cancelled or started. Never ends a newer attempt. */ endSendAttempt(id: string): void; + /** + * Marks the input as submitting from the user's submit, before the checks and waits that precede its send attempt, such + * as for the session to be ready or the host's confirmation, until the returned disposable is disposed: also as the + * submit is cancelled or fails. + */ + beginSubmit(): IDisposable; + /** Whether a submit is under way (see {@link beginSubmit}). */ + readonly isSubmitting: boolean; /** * Registers the composer's input as the one that shows an input its host gives back ({@link ComposerInputSnapshot.restore}), * until the returned disposable is disposed. `restorer` returns whether it showed it: not while it cannot, such as while @@ -90,6 +98,7 @@ export class ComposerInputSnapshot implements IComposerInputSnapshot { private _attempt: IComposerSendAttempt | undefined; private _restorer: ((input: IChatDraft) => boolean) | undefined; + private _submits = 0; constructor(state?: IComposerInputState) { this._input = state?.input ?? { inputText: '', attachments: [] }; @@ -115,6 +124,15 @@ export class ComposerInputSnapshot implements IComposerInputSnapshot { } } + beginSubmit(): IDisposable { + this._submits++; + return toDisposable(() => this._submits--); + } + + get isSubmitting(): boolean { + return this._submits > 0; + } + registerRestorer(restorer: (input: IChatDraft) => boolean): IDisposable { this._restorer = restorer; return toDisposable(() => { diff --git a/src/vs/workbench/contrib/sessions/test/browser/chat/sharedChatInputComposer.test.ts b/src/vs/workbench/contrib/sessions/test/browser/chat/sharedChatInputComposer.test.ts index 23ae8b6fea4f0e..42d7dda66852e4 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/chat/sharedChatInputComposer.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/chat/sharedChatInputComposer.test.ts @@ -426,6 +426,20 @@ suite('SharedChatInputComposer', () => { }); } + for (const confirmed of [true, false]) { + test(`a submit is submitting from before the host confirms it until it is ${confirmed ? 'sent' : 'refused'}, so its host doesn't retarget it meanwhile`, async () => { + const confirmation = new DeferredPromise(); + const snapshot = new ComposerInputSnapshot(); + const { composer } = createComposer({ confirmSend: confirmation, focused: true, inputSnapshot: snapshot }); + const sending = composer.submit(false); + const whileConfirming = { submitting: snapshot.isSubmitting, attempt: !!snapshot.get().attempt }; + confirmation.complete(confirmed); + await sending; + + assert.deepStrictEqual({ whileConfirming, after: snapshot.isSubmitting }, { whileConfirming: { submitting: true, attempt: false }, after: false }); + }); + } + test('focused as it opens, such as by New Session in a chat window, its input is the chat widget that has focus last, so Enter sends it, not the chat focused before', () => { const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(IEditorGroupsService, {}); diff --git a/src/vs/workbench/contrib/sessions/test/browser/chatEditor/chatWindowComposer.test.ts b/src/vs/workbench/contrib/sessions/test/browser/chatEditor/chatWindowComposer.test.ts index dbf7be4611e985..427e94ce642d55 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/chatEditor/chatWindowComposer.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/chatEditor/chatWindowComposer.test.ts @@ -579,7 +579,10 @@ suite('Chat window composer', () => { const aimsAt = () => composer.requestedTarget.get()?.folderUri?.path; const retarget = (folder: string, from: string) => ({ done: composer.retargetFolder(URI.file(folder), URI.file(from)), aimsAt: aimsAt() }); - // Submitted: its checks and the draft's preparation are under way, its request not accepted yet + // Submitted: its checks, such as for its session to be ready or the host's confirmation, are under way before its send attempt + const submitting = composer.input.beginSubmit(); + const whileChecking = retarget('/repo.worktrees/a', '/repo'); + // Its send attempt: the draft's preparation is under way, its request not accepted yet const attempt = composer.input.beginSendAttempt({ inputText: 'hello', attachments: [] }); const beforeAccepted = retarget('/repo.worktrees/a', '/repo'); // Accepted: its request is under way @@ -589,9 +592,11 @@ suite('Chat window composer', () => { // It did not start its session: the composer shows its draft again and follows its host again onDidEnd.fire({ session: draft, owner: slot, started: false, readopted: false }); composer.input.endSendAttempt(attempt); + submitting.dispose(); const afterEnded = retarget('/repo.worktrees/a', '/repo'); - assert.deepStrictEqual({ beforeAccepted, accepted, afterEnded }, { + assert.deepStrictEqual({ whileChecking, beforeAccepted, accepted, afterEnded }, { + whileChecking: { done: false, aimsAt: '/repo' }, beforeAccepted: { done: false, aimsAt: '/repo' }, accepted: { done: false, aimsAt: '/repo' }, afterEnded: { done: true, aimsAt: '/repo.worktrees/a' }, From e8f9dbc0cb315c97f66d702a82f4b0f0df96c48c Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 08:49:35 +0100 Subject: [PATCH 109/165] Chat window composer: a restored composer keeps whether its host or the user picked its folder Store the folder its host aimed it at, or null once the user picked its folder, and restore it, so a restored composer whose folder the user picked does not follow its host to another folder. Composers stored before keep their folder, as the user's. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/chatEditor/chatWindowComposer.ts | 27 +++++++++++++++---- .../chatEditor/chatWindowComposer.test.ts | 25 +++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts b/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts index e73ad523ddc9cf..1c3030fc6c6151 100644 --- a/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts +++ b/src/vs/workbench/contrib/sessions/browser/chatEditor/chatWindowComposer.ts @@ -190,6 +190,11 @@ export interface IChatWindowComposerInit { readonly allowedFolders?: IObservable; /** How it shows one of its {@link allowedFolders}, such as a linked checkout of the window's folder. */ readonly getFolderPresentation?: (folderUri: URI) => IAllowedFolderPresentation | undefined; + /** + * The folder its host aimed it at, which it follows to another folder (see `ChatWindowComposerModel.retargetFolder`), or + * `null` when the user picked its folder. The folder of its {@link target} when undefined, as for a new composer. + */ + readonly hostFolder?: URI | null; } /** @@ -262,7 +267,7 @@ export class ChatWindowComposerModel extends Disposable implements INewSessionDr return folders.length && targetFolder && !folders.some(folder => extUriBiasedIgnorePathCase.isEqual(folder, targetFolder)) ? [...folders, targetFolder] : folders; }); this._requestedTarget.set(init.target, undefined); - this._hostFolder = init.target?.folderUri; + this._hostFolder = init.hostFolder === undefined ? init.target?.folderUri : init.hostFolder ?? undefined; this.input = new ComposerInputSnapshot(init.input); this.slot = this._register(_sessionsManagementService.createNewSessionSlot({ readoptOnFailure: session => this._isRequested(session) })); this._register(sessionGroupsService.setDraftSlotGroup(this.slot.id, init.groupId)); @@ -291,10 +296,15 @@ export class ChatWindowComposerModel extends Disposable implements INewSessionDr super.dispose(); } - /** What it stores to be restored, such as after a reload: what it aims for, its group and its input. */ + /** + * What it stores to be restored, such as after a reload: what it aims for, whether its host or the user picked that, its + * group and its input. + */ toStored(): IStoredChatWindowComposer { const target = this._requestedTarget.get(); - return { resource: this.resource, ...(target ? { target } : {}), ...(this.groupId ? { groupId: this.groupId } : {}), ...this.input.get() }; + const current = target?.folderUri; + const hostFolder = current && this._hostFolder && extUriBiasedIgnorePathCase.isEqual(current, this._hostFolder) ? this._hostFolder : null; + return { resource: this.resource, ...(target ? { target } : {}), hostFolder, ...(this.groupId ? { groupId: this.groupId } : {}), ...this.input.get() }; } setRequestedTarget(target: INewSessionTarget, reason: 'user' | 'background' = 'user'): void { @@ -415,6 +425,8 @@ export class ChatWindowComposerModel extends Disposable implements INewSessionDr export interface IStoredChatWindowComposer extends IComposerInputState { readonly resource: URI; readonly target?: INewSessionTarget; + /** The folder its host aimed it at, or `null` when the user picked its folder; absent from composers stored before it was. */ + readonly hostFolder?: URI | null; readonly groupId?: string; } @@ -430,10 +442,11 @@ function isOptionalString(value: unknown): value is string | undefined { /** The stored composer that `value` is, unmarshalled; throws when it is none. */ function fromStoredComposer(value: unknown): IStoredChatWindowComposer { - const entry = value as { resource?: unknown; target?: Partial>; groupId?: unknown; input?: unknown; attempt?: { id?: unknown; input?: unknown; session?: unknown } } | undefined; + const entry = value as { resource?: unknown; target?: Partial>; hostFolder?: unknown; groupId?: unknown; input?: unknown; attempt?: { id?: unknown; input?: unknown; session?: unknown } } | undefined; const target = entry?.target; const attempt = entry?.attempt; if (!entry || !URI.isUri(entry.resource) || !isChatWindowComposerResource(entry.resource) || !isOptionalString(entry.groupId) + || (entry.hostFolder !== undefined && entry.hostFolder !== null && !URI.isUri(entry.hostFolder)) || (target !== undefined && (typeof target !== 'object' || target === null || (target.folderUri !== undefined && !URI.isUri(target.folderUri)) || !isOptionalString(target.providerId) || !isOptionalString(target.sessionTypeId) || !isOptionalString(target.modelId) || !isOptionalString(target.modeId) || (target.requireDevContainer !== undefined && typeof target.requireDevContainer !== 'boolean'))) @@ -443,6 +456,7 @@ function fromStoredComposer(value: unknown): IStoredChatWindowComposer { return { resource: entry.resource, ...(target ? { target: target as INewSessionTarget } : {}), + ...(entry.hostFolder !== undefined ? { hostFolder: entry.hostFolder as URI | null } : {}), ...(entry.groupId ? { groupId: entry.groupId } : {}), input: fromStoredChatDraft(entry.input), ...(attempt ? { attempt: { id: attempt.id as string, input: fromStoredChatDraft(attempt.input), ...(attempt.session ? { session: attempt.session as URI } : {}) } } : {}), @@ -453,6 +467,7 @@ function toStoredComposer(composer: IStoredChatWindowComposer): unknown { return { resource: composer.resource, target: composer.target, + hostFolder: composer.hostFolder, groupId: composer.groupId, input: toStoredChatDraft(composer.input), attempt: composer.attempt && { id: composer.attempt.id, session: composer.attempt.session, input: toStoredChatDraft(composer.attempt.input) }, @@ -542,7 +557,9 @@ export class ChatWindowComposers extends Disposable { } const input = stored.attempt?.input ?? stored.input; this._restoredAs.set(key, key); - return { kind: 'composer', composer: this.create(resource, { target: stored.target, groupId: stored.groupId, input: { input }, ...folders }, openStartedSession) }; + // A composer stored before its host's folder was does not tell whether the user picked its folder: it keeps it, as one the user picked + const hostFolder = stored.hostFolder === undefined ? null : stored.hostFolder; + return { kind: 'composer', composer: this.create(resource, { target: stored.target, hostFolder, groupId: stored.groupId, input: { input }, ...folders }, openStartedSession) }; } /** The session that a foreground send of the stored composer of `resource` went out with, if any. */ diff --git a/src/vs/workbench/contrib/sessions/test/browser/chatEditor/chatWindowComposer.test.ts b/src/vs/workbench/contrib/sessions/test/browser/chatEditor/chatWindowComposer.test.ts index 427e94ce642d55..30de5a8298745b 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/chatEditor/chatWindowComposer.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/chatEditor/chatWindowComposer.test.ts @@ -655,6 +655,31 @@ suite('Chat window composer', () => { }); }); + test('a restored composer follows its host to another folder only while its host had aimed it, not once the user picked its folder, nor one stored before that was stored', async () => { + const store = disposables.add(new DisposableStore()); + const { instantiationService, storageService } = createServices(store); + const resource = (name: string) => URI.parse(`vscode-agents-composer:/${name}`); + const open = async () => { }; + const composers = instantiationService.createInstance(ChatWindowComposers, async () => undefined); + const hostAimed = composers.create(resource('host'), { target: { folderUri: URI.file('/repo') } }, open); + const userPicked = composers.create(resource('user'), { target: { folderUri: URI.file('/repo') } }, open); + userPicked.setRequestedTarget({ folderUri: URI.file('/repo.worktrees/c') }, 'user'); + storageService.testEmitWillSaveState(WillSaveStateReason.NONE); + // A composer stored by a build that did not store its host's folder + storageService.store(CHAT_WINDOW_COMPOSERS_STORAGE_KEY, stringify([...parse(storageService.get(CHAT_WINDOW_COMPOSERS_STORAGE_KEY, StorageScope.WORKSPACE)!), { resource: resource('older'), target: { folderUri: URI.file('/repo') }, input: { inputText: '', attachments: [] } }]), StorageScope.WORKSPACE, StorageTarget.MACHINE); + composers.dispose(); + [hostAimed, userPicked].forEach(composer => composer.dispose()); + + const reloaded = store.add(instantiationService.createInstance(ChatWindowComposers, async () => undefined)); + const follows = async (name: string, from: string) => { + const restored = await reloaded.restore(resource(name), open); + const composer = restored?.kind === 'composer' ? store.add(restored.composer) : undefined; + return composer?.retargetFolder(URI.file('/repo.worktrees/a'), URI.file(from)); + }; + + assert.deepStrictEqual({ host: await follows('host', '/repo'), user: await follows('user', '/repo.worktrees/c'), older: await follows('older', '/repo') }, { host: true, user: false, older: false }); + }); + test('the stored composers the restoration of the chat windows did not claim are forgotten, while open ones and claimed ones are stored again', () => { const store = disposables.add(new DisposableStore()); const { instantiationService, storageService } = createServices(store); From 6105297ed233357918ac405a9be5deb0208847c8 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 09:05:58 +0100 Subject: [PATCH 110/165] Worktree support: trace why a shown chat does not move the folder, and follow a chat in the folder's own location by its real path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostWorktree.contribution.ts | 34 ++++++++++++++++++- .../agentHost/agentHostWorktree.test.ts | 16 +++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts index df59ccb3efb2e1..4c66eb2a607c17 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts @@ -175,6 +175,7 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe this._sessionsListener.clear(); // A chat the user opens follows; so does one that a first request made, on its own, as the user did not pick it const follows = shown?.reason === 'open' || shown?.reason === 'commit'; + this._trace(shown ? `shown ${shown.resource.toString()} (${shown.reason})` : 'shown: none'); this._shown = shown && !isUntitledChatSession(shown.resource) ? { resource: shown.resource, workingDirectory: undefined, followOnceKnown: follows } : undefined; if (!shown || shown.reason === 'open') { // The user's latest choice: no switch asked for before applies, also where this chat moves nothing @@ -289,6 +290,14 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe return typeof path === 'string' && path ? URI.file(path) : undefined; } + /** Where {@link _workingDirectory} found the working directory of `resource`, for the trace. */ + private _workingDirectorySource(resource: URI): string { + if (this._sessionSources.getWorkingDirectory(resource)) { + return 'session layer'; + } + return isLocalAgentHostTarget(resource.scheme) && this._agentSessionsService.getSession(resource) ? 'Chat view sessions list' : 'none'; + } + /** * Whether the chat of `resource` may work in a folder that the folder follows: any but a local chat, such as an agent * host session of any session layer, whose working directory may only become known once its first request ran. @@ -321,6 +330,7 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe const folder = this._workspaceContextService.getWorkspace().folders[0]; const primary = this._workspaceContextService.getWorkspace().uri; if (!folder || !primary || !this._folderLocationService.canSetLocation(folder)) { + this._trace(`not followed: no folder that can move (folder ${folder?.uri.toString()}, primary ${primary?.toString()})`); return; } let target: URI | undefined; @@ -328,14 +338,17 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe target = primary; } else if (this._isFollowable(shown.resource)) { shown.workingDirectory = this._workingDirectory(shown.resource); + this._trace(`working directory of ${shown.resource.toString()}: ${shown.workingDirectory?.toString() ?? 'unknown'} (from ${this._workingDirectorySource(shown.resource)})`); // Not listed with its working directory yet, such as a session its first request just started: it follows as it is this._watchWorkingDirectory(shown); target = shown.workingDirectory; } if (!target || target.scheme !== Schemas.file || !this._enabled) { + this._trace(`not followed: ${shown.resource.toString()} ${!this._isFollowable(shown.resource) ? 'is not followable' : !target ? 'has no working directory yet' : target.scheme !== Schemas.file ? `works in ${target.toString()}, not a local folder` : 'worktree support is off'}`); return; } if (automatic && this._switching > 0) { + this._trace(`follows ${shown.resource.toString()} to ${target.toString()} once the switch under way settles`); // Gives way to the switch under way, and follows where the chat works by then once that settles: also where that // is where the folder is now, which the switch under way would leave this._followWhenIdle = { shown, request: this._request }; @@ -343,9 +356,11 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe } if (extUriBiasedIgnorePathCase.isEqual(target, folder.uri)) { // Where the folder is: the chat is the last one of its location also without a move + this._trace(`not followed: the folder is at ${target.toString()} already`); this._rememberSession(folder.uri, shown.resource); return; } + this._trace(`switches for ${shown.resource.toString()} from ${folder.uri.toString()} to ${target.toString()} (primary ${primary.toString()}${automatic ? ', automatic' : ''})`); void this._switch(shown.resource, primary, target, automatic ? this._foreground : undefined); } @@ -376,11 +391,14 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe return; } if (location) { + this._trace(`${target.toString()} is in the location ${location.uri.toString()} of ${primary.toString()}`); direction = 'toAlternate'; target = location.uri; - } else if (extUriBiasedIgnorePathCase.isEqualOrParent(target, primary)) { + } else if (extUriBiasedIgnorePathCase.isEqualOrParent(target, primary) || (exists && extUriBiasedIgnorePathCase.isEqualOrParent(await this._real(target), await this._real(primary)))) { + // Also by its real path, such as /private/tmp/repo of the folder /tmp/repo target = primary; } else { + this._trace(`not switched: ${target.toString()} ${exists ? `is no location of ${primary.toString()}` : 'does not exist'}`); if (!exists) { if (request === this._request && !automatic) { this._notificationService.info(localize('worktree.missing', "The folder {0} of this chat no longer exists.", this._labelService.getUriLabel(target, { noPrefix: true }))); @@ -393,6 +411,7 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe } if (this._isAt(target)) { // Where the folder is already, such as a chat in a folder inside the worktree it is at + this._trace(`not switched: the folder is at ${target.toString()} already`); if (request === this._request && this._enabled) { this._rememberSession(target, session); } @@ -480,6 +499,19 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe }); } + /** `resource` with its symbolic links resolved, or itself where that fails. */ + private async _real(resource: URI): Promise { + try { + return await this._fileService.realpath(resource) ?? resource; + } catch { + return resource; + } + } + + private _trace(message: string): void { + this._logService.trace(`[worktree] ${message}`); + } + private _isAt(location: URI): boolean { const folder = this._workspaceContextService.getWorkspace().folders[0]; return !!folder && extUriBiasedIgnorePathCase.isEqual(folder.uri, location); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts index fd213db9851e60..eef1978039b97e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts @@ -48,7 +48,7 @@ suite('AgentHostWorktree', () => { const created = URI.file('/work/vscode.worktrees/created'); /** The feature in a window whose folder is at `at`, recording the moves it asks for, the chats it opens and its experiment triggers. */ - function createWorktree(options: { readonly enabled: boolean; readonly at?: URI; readonly canSetLocation?: boolean; readonly worktrees?: readonly URI[]; readonly resolving?: Promise; readonly terminalCwd?: URI; readonly setting?: Promise; readonly resolvingFor?: Record> }) { + function createWorktree(options: { readonly enabled: boolean; readonly at?: URI; readonly canSetLocation?: boolean; readonly worktrees?: readonly URI[]; readonly resolving?: Promise; readonly terminalCwd?: URI; readonly setting?: Promise; readonly resolvingFor?: Record>; readonly realPaths?: Record }) { const worktrees = options.worktrees ?? [worktree, created]; const marks: string[] = []; const terminal = upcastPartial({ @@ -150,7 +150,10 @@ suite('AgentHostWorktree', () => { onDidChangeLocation.fire({ folder: folder(), reason: moveOptions?.reason === 'user' ? 'user' : 'programmatic' }); }; instantiationService.stub(IWorkspaceEditingService, upcastPartial({ setFolderLocation })); - instantiationService.stub(IFileService, upcastPartial({ stat: async () => upcastPartial({}) })); + instantiationService.stub(IFileService, upcastPartial({ + stat: async () => upcastPartial({}), + realpath: async (resource: URI) => { const real = options.realPaths?.[resource.path]; return real ? URI.file(real) : resource; }, + })); instantiationService.stub(ILabelService, upcastPartial({ getUriLabel: (uri: URI) => uri.path })); instantiationService.stub(INotificationService, new TestNotificationService()); instantiationService.stub(ITerminalService, upcastPartial({ instances: options.terminalCwd ? [terminal] : [], onDidCreateInstance: Event.None })); @@ -217,6 +220,15 @@ suite('AgentHostWorktree', () => { }); }); + test('follows a chat that works in the folder\'s own location by another path, such as /private/tmp of /tmp, back to the folder', async () => { + const feature = createWorktree({ enabled: true, at: worktree, realPaths: { '/private/work/vscode/src': '/work/vscode/src' } }); + feature.workingDirectories.set(agentSession('a').toString(), '/private/work/vscode/src'); + feature.show(agentSession('a')); + await settle(); + + assert.deepStrictEqual(feature.moves, ['/work/vscode']); + }); + test('opens the last chat of a location the user moves the folder to themselves', async () => { const feature = createWorktree({ enabled: true }); feature.workingDirectories.set(agentSession('a').toString(), worktree.fsPath); From b014b2686bba6e0c156efb338e5fc1d9a74592e9 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 09:11:33 +0100 Subject: [PATCH 111/165] Worktree support: the composers' folder choices read the worktrees again as the window's sessions come to work elsewhere, not on every session change Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostWorktreeComposerFolders.ts | 30 +++++++++++++++---- .../agentHostWorktreeComposerFolders.test.ts | 29 +++++++++++++++++- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts index fa6d542b8df437..a22365a55d27fe 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { RunOnceScheduler } from '../../../../../base/common/async.js'; import { onUnexpectedError } from '../../../../../base/common/errors.js'; import { Emitter } from '../../../../../base/common/event.js'; import { Disposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; @@ -49,9 +50,17 @@ export class AgentHostWorktreeComposerFolders extends Disposable { constructor( @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @IWorkspaceFolderLocationService private readonly _folderLocationService: IWorkspaceFolderLocationService, + @IAgentHostWorktreeSessionSources sessionSources: IAgentHostWorktreeSessionSources, ) { super(); this._register(_folderLocationService.onDidChangeProviders(() => this.refresh())); + // A session can come to work in a new worktree meanwhile, such as one in the background: read once as that settles + const refreshForSessions = this._register(new RunOnceScheduler(() => this.refresh(), 500)); + this._register(sessionSources.onDidChange(() => { + if (this._movableFolder()) { + refreshForSessions.schedule(); + } + })); // The location the folder is at shows as one also before or without its provider's list this._register(_folderLocationService.onDidChangeLocation(() => this._update(this._locations.get()))); // Also as the window's folders change, such as as the folder moves: the service tells whether it can move again then @@ -165,20 +174,29 @@ export class AgentHostWorktreeSessionLayerSource extends Disposable implements I // As any session it knows is added, removed or changes, also one outside the listed ones (getAllSessions), and as a // session comes to work elsewhere, such as in the worktree its first request created const sessionsChanged = observableSignalFromEvent(this, _sessionsManagementService.onDidChangeSession); - let first = true; + // Only as where they work changes, not as they change otherwise, such as their status, which reads no folders again + let known: string | undefined; this._register(autorun(reader => { sessionsChanged.read(reader); + const working: string[] = []; for (const session of _sessionsManagementService.getAllSessions()) { - session.isArchived.read(reader); - session.workspace.read(reader); + const local = session.providerId === LOCAL_AGENT_HOST_PROVIDER_ID && !session.isArchived.read(reader); + const sessionDirectory = session.workspace.read(reader)?.folders[0]?.workingDirectory; for (const chat of session.chats.read(reader)) { - chat.workspace.read(reader); + const chatWorkspace = chat.workspace.read(reader); + if (local) { + working.push(`${chat.resource.toString()}=${(chatWorkspace?.folders[0]?.workingDirectory ?? sessionDirectory)?.toString()}`); + } + } + if (local) { + working.push(`*=${sessionDirectory?.toString()}`); } } - if (!first) { + const key = working.join('\n'); + if (known !== undefined && key !== known) { this._onDidChange.fire(); } - first = false; + known = key; })); } diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts index cb7a5653dbdfa3..33e0dda0cc0957 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts @@ -12,6 +12,7 @@ import { observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IFolderLocation, IWorkspaceFolderLocationService } from '../../../../../../platform/workspace/common/folderLocations.js'; import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder, IWorkspaceFoldersChangeEvent, WorkbenchState } from '../../../../../../platform/workspace/common/workspace.js'; @@ -64,6 +65,11 @@ suite('AgentHostWorktreeComposerFolders', () => { getLocation: folder => atAlternate(folder) && options.currentLocation ? { ...options.currentLocation, providerId: 'agentHost.sessionWorktrees', builtIn: true } : undefined, getLocations: async primary => { asked.push(primary.path); return locations; }, })); + // Where the window's sessions work, which tells as a session comes to work in another worktree + const onDidChangeSessionSource = store.add(new Emitter()); + const sessionSources = store.add(new AgentHostWorktreeSessionSources()); + store.add(sessionSources.registerSource({ getWorkingDirectory: () => undefined, getWorkingDirectories: () => [], onDidChange: onDidChangeSessionSource.event })); + instantiationService.stub(IAgentHostWorktreeSessionSources, sessionSources); const composerFolders = store.add(instantiationService.createInstance(AgentHostWorktreeComposerFolders)); composerFolders.refresh(); return { @@ -76,6 +82,7 @@ suite('AgentHostWorktreeComposerFolders', () => { moveTo: (next: { uri: string; primaryUri?: string }[]) => { folders = next; onDidChangeWorkspaceFolders.fire({ added: [], removed: [], changed: [] }); }, addWorktree: (location: IFolderLocation) => { locations = [...locations, location]; }, providersChanged: () => onDidChangeProviders.fire(), + sessionsChanged: () => onDidChangeSessionSource.fire(), }; } @@ -176,6 +183,7 @@ suite('AgentHostWorktreeComposerFolders', () => { isAtAlternateLocation: () => false, getLocations: (_primary, cancellation) => { token = cancellation; return read.p; }, })); + instantiationService.stub(IAgentHostWorktreeSessionSources, store.add(new AgentHostWorktreeSessionSources())); const composerFolders = instantiationService.createInstance(AgentHostWorktreeComposerFolders); composerFolders.refresh(); composerFolders.dispose(); @@ -195,6 +203,21 @@ suite('AgentHostWorktreeComposerFolders', () => { assert.deepStrictEqual(asked, ['/work/vscode', '/work/vscode']); }); + test('offers a worktree that a session in the background comes to work in, read once as the sessions settle, while the folder stays where it is', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { asked, snapshot, addWorktree, sessionsChanged } = create({ folders: [{ uri: '/work/vscode' }] }); + await timeout(0); + asked.splice(0); + addWorktree(worktree('/work/vscode.worktrees/c', 'feature-c')); + sessionsChanged(); + sessionsChanged(); + await timeout(500); + + assert.deepStrictEqual({ folders: snapshot().folders, asked }, { + folders: ['/work/vscode', '/work/vscode.worktrees/a', '/work/vscode.worktrees/b', '/work/vscode.worktrees/c'], + asked: ['/work/vscode'], + }); + })); + test('with worktree support and AI features on, the composers offer the folder\'s locations and follow the folder as it moves, without a chat part', async () => { const onDidChangeWorkspaceFolders = store.add(new Emitter()); let at = '/work/vscode'; @@ -346,12 +369,16 @@ suite('AgentHostWorktreeComposerFolders', () => { const source = store.add(instantiationService.createInstance(AgentHostWorktreeSessionLayerSource)); let changes = 0; store.add(source.onDidChange(() => changes++)); + // Any session changing otherwise, such as its status, changes nothing of where they work + onDidChangeSession.fire(undefined as never); + const unrelated = changes; sessions.push(hidden); onDidChangeSession.fire(undefined as never); const added = changes; chatWorkspace.set(workspace('/work/vscode.worktrees/hidden'), undefined); - assert.deepStrictEqual({ added, changes, workingDirectory: source.getWorkingDirectory(chat.resource)?.path, all: source.getWorkingDirectories().map(uri => uri.path) }, { + assert.deepStrictEqual({ unrelated, added, changes, workingDirectory: source.getWorkingDirectory(chat.resource)?.path, all: source.getWorkingDirectories().map(uri => uri.path) }, { + unrelated: 0, added: 1, changes: 2, workingDirectory: '/work/vscode.worktrees/hidden', From 9237cfeb869c45edded6a4dcfbac989ba757448e Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 09:24:07 +0100 Subject: [PATCH 112/165] Worktree support: tell as a session comes to work elsewhere in any of its folders, and fail a move whose folder's real path cannot be read Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostWorktree.contribution.ts | 9 ++++--- .../agentHost/agentHostWorktree.test.ts | 25 ++++++++++++++---- .../agentHostWorktreeComposerFolders.ts | 10 ++++--- .../agentHostWorktreeComposerFolders.test.ts | 26 +++++++++++++++++++ 4 files changed, 58 insertions(+), 12 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts index 4c66eb2a607c17..3687195b809236 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktree.contribution.ts @@ -499,12 +499,15 @@ export class AgentHostWorktreeContribution extends Disposable implements IWorkbe }); } - /** `resource` with its symbolic links resolved, or itself where that fails. */ + /** `resource` with its symbolic links resolved, or itself where its file system has no real paths or it is not there; any other failure throws. */ private async _real(resource: URI): Promise { try { return await this._fileService.realpath(resource) ?? resource; - } catch { - return resource; + } catch (error) { + if (error instanceof Error && toFileOperationResult(error) === FileOperationResult.FILE_NOT_FOUND) { + return resource; + } + throw error; } } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts index eef1978039b97e..b92bbabb5b1aad 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktree.test.ts @@ -15,7 +15,7 @@ import { IConfigurationService } from '../../../../../../../platform/configurati import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IContextKeyService } from '../../../../../../../platform/contextkey/common/contextkey.js'; import { MockContextKeyService } from '../../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; -import { IFileService, IFileStatWithMetadata } from '../../../../../../../platform/files/common/files.js'; +import { createFileSystemProviderError, FileSystemProviderErrorCode, IFileService, IFileStatWithMetadata } from '../../../../../../../platform/files/common/files.js'; import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILabelService } from '../../../../../../../platform/label/common/label.js'; import { ILogService, NullLogService } from '../../../../../../../platform/log/common/log.js'; @@ -48,7 +48,7 @@ suite('AgentHostWorktree', () => { const created = URI.file('/work/vscode.worktrees/created'); /** The feature in a window whose folder is at `at`, recording the moves it asks for, the chats it opens and its experiment triggers. */ - function createWorktree(options: { readonly enabled: boolean; readonly at?: URI; readonly canSetLocation?: boolean; readonly worktrees?: readonly URI[]; readonly resolving?: Promise; readonly terminalCwd?: URI; readonly setting?: Promise; readonly resolvingFor?: Record>; readonly realPaths?: Record }) { + function createWorktree(options: { readonly enabled: boolean; readonly at?: URI; readonly canSetLocation?: boolean; readonly worktrees?: readonly URI[]; readonly resolving?: Promise; readonly terminalCwd?: URI; readonly setting?: Promise; readonly resolvingFor?: Record>; readonly realPaths?: Record; readonly realPathError?: Error }) { const worktrees = options.worktrees ?? [worktree, created]; const marks: string[] = []; const terminal = upcastPartial({ @@ -152,10 +152,16 @@ suite('AgentHostWorktree', () => { instantiationService.stub(IWorkspaceEditingService, upcastPartial({ setFolderLocation })); instantiationService.stub(IFileService, upcastPartial({ stat: async () => upcastPartial({}), - realpath: async (resource: URI) => { const real = options.realPaths?.[resource.path]; return real ? URI.file(real) : resource; }, + realpath: async (resource: URI) => { if (options.realPathError) { throw options.realPathError; } const real = options.realPaths?.[resource.path]; return real ? URI.file(real) : resource; }, })); instantiationService.stub(ILabelService, upcastPartial({ getUriLabel: (uri: URI) => uri.path })); - instantiationService.stub(INotificationService, new TestNotificationService()); + // The kinds of the notifications it shows, such as of a move that failed + const notices: string[] = []; + const notificationService = new TestNotificationService(); + instantiationService.stub(INotificationService, upcastPartial({ + info: () => { notices.push('info'); return notificationService.info(''); }, + error: () => { notices.push('error'); return notificationService.error(''); }, + })); instantiationService.stub(ITerminalService, upcastPartial({ instances: options.terminalCwd ? [terminal] : [], onDidCreateInstance: Event.None })); instantiationService.stub(IStorageService, store.add(new TestStorageService())); instantiationService.stub(ITelemetryService, telemetryService); @@ -176,7 +182,7 @@ suite('AgentHostWorktree', () => { /** The user moves the folder in the window's title, which the workspace makes like any other move. */ const userStartsMoveTo = (location: URI) => setFolderLocation(location, { reason: 'user' }); const commit = (original: URI, committed: URI) => onDidCommitSession.fire({ original, committed }); - return { show, workingDirectories, onDidChangeSessions, sessionLayerDirectories, onDidChangeSessionLayer, moves, applied, opened, providers, triggers: telemetryService.triggers, setEnabled, userMovesTo, userStartsMoveTo, commit, contribution, marks }; + return { show, workingDirectories, onDidChangeSessions, sessionLayerDirectories, onDidChangeSessionLayer, moves, applied, opened, providers, triggers: telemetryService.triggers, setEnabled, userMovesTo, userStartsMoveTo, commit, contribution, marks, notices }; } const settle = () => new Promise(resolve => setTimeout(resolve, 0)); @@ -229,6 +235,15 @@ suite('AgentHostWorktree', () => { assert.deepStrictEqual(feature.moves, ['/work/vscode']); }); + test('a chat whose folder\'s real path cannot be read, other than as missing, fails to move the folder with an error rather than staying silent', async () => { + const feature = createWorktree({ enabled: true, at: worktree, realPathError: createFileSystemProviderError('EACCES', FileSystemProviderErrorCode.NoPermissions) }); + feature.workingDirectories.set(agentSession('a').toString(), '/private/work/vscode/src'); + feature.show(agentSession('a')); + await settle(); + + assert.deepStrictEqual({ moves: feature.moves, notices: feature.notices }, { moves: [], notices: ['error'] }); + }); + test('opens the last chat of a location the user moves the folder to themselves', async () => { const feature = createWorktree({ enabled: true }); feature.workingDirectories.set(agentSession('a').toString(), worktree.fsPath); diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts index a22365a55d27fe..5eced27b21eac4 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts @@ -24,7 +24,7 @@ import { ChatConfiguration } from '../../../chat/common/constants.js'; import { IAllowedFolderPresentation } from '../chat/sessionWorkspacePicker.js'; import { IEditorWindowComposerFoldersService } from '../editorWindow/editorWindowComposerFolders.js'; import { IAgentHostWorktreeSessionSource, IAgentHostWorktreeSessionSources } from '../../../chat/browser/agentSessions/agentHost/agentHostWorktreeSessionSources.js'; -import { ISession } from '../../../../services/sessions/common/session.js'; +import { ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../common/agentHostSessionsProvider.js'; import { ISessionsManagementService } from '../../common/sessionsManagement.js'; @@ -179,17 +179,19 @@ export class AgentHostWorktreeSessionLayerSource extends Disposable implements I this._register(autorun(reader => { sessionsChanged.read(reader); const working: string[] = []; + const directories = (workspace: ISessionWorkspace | undefined) => workspace?.folders.map(folder => folder.workingDirectory.toString()).join(',') ?? ''; for (const session of _sessionsManagementService.getAllSessions()) { const local = session.providerId === LOCAL_AGENT_HOST_PROVIDER_ID && !session.isArchived.read(reader); - const sessionDirectory = session.workspace.read(reader)?.folders[0]?.workingDirectory; + const sessionWorkspace = session.workspace.read(reader); for (const chat of session.chats.read(reader)) { const chatWorkspace = chat.workspace.read(reader); if (local) { - working.push(`${chat.resource.toString()}=${(chatWorkspace?.folders[0]?.workingDirectory ?? sessionDirectory)?.toString()}`); + // Every folder it works in, which the worktrees count; the first is where it follows + working.push(`${chat.resource.toString()}=${directories(chatWorkspace ?? sessionWorkspace)}`); } } if (local) { - working.push(`*=${sessionDirectory?.toString()}`); + working.push(`*=${directories(sessionWorkspace)}`); } } const key = working.join('\n'); diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts index 33e0dda0cc0957..61b9c4bd4d10ef 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts @@ -385,4 +385,30 @@ suite('AgentHostWorktreeComposerFolders', () => { all: ['/work/vscode', '/work/vscode.worktrees/hidden'], }); }); + + test('the session layer\'s source tells as a session comes to work in a new worktree in a folder other than its first', () => { + const folder = (path: string) => ({ root: URI.file(path), workingDirectory: URI.file(path), name: path, description: undefined }); + const sessionWorkspace = observableValue('workspace', upcastPartial({ folders: [folder('/work/other'), folder('/work/vscode')] })); + const session = upcastPartial({ + providerId: LOCAL_AGENT_HOST_PROVIDER_ID, + isArchived: observableValue('archived', false), + workspace: sessionWorkspace, + chats: observableValue('chats', [upcastPartial({ resource: URI.from({ scheme: 'ahp-chat', path: '/multi' }), workspace: observableValue('workspace', undefined) })]), + }); + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(ISessionsManagementService, upcastPartial({ + onDidChangeSession: Event.None, + getAllSessions: () => [session], + getSessionForChatResource: () => undefined, + })); + const source = store.add(instantiationService.createInstance(AgentHostWorktreeSessionLayerSource)); + let changes = 0; + store.add(source.onDidChange(() => changes++)); + sessionWorkspace.set(upcastPartial({ folders: [folder('/work/other'), folder('/work/vscode.worktrees/new')] }), undefined); + + assert.deepStrictEqual({ changes, all: source.getWorkingDirectories().map(uri => uri.path) }, { + changes: 1, + all: ['/work/other', '/work/vscode.worktrees/new'], + }); + }); }); From 7522545457554a1ef02f0dd75cc9b380d355bff5 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 09:33:07 +0100 Subject: [PATCH 113/165] Agents chat part: its host reports the chat of the active tab, reading the chat part its frame has rather than the delayed service while it is created The chat part's constructor announced itself to its frame, which re-ran the host's shown chat and the presenter's active chat. These read the delayed IAgentsChatPartService as it was still being created, which created it a second time; they then followed that second instance, whose chats never change. The frame now has the chat part itself, which these read. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentsView/agentsChatPart.ts | 4 +- .../agentsView/agentsChatPartContribution.ts | 9 +-- .../browser/agentsView/agentsChatPartFrame.ts | 23 +++++--- .../browser/agentsView/agentsChatViewHost.ts | 4 +- .../browser/agentsView/agentsChatPart.test.ts | 55 +++++++++++++++++-- .../agentsView/agentsChatViewHost.test.ts | 15 +++-- 6 files changed, 83 insertions(+), 27 deletions(-) diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts index 3117324c24d915..e0058d01911acf 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPart.ts @@ -302,8 +302,8 @@ export class AgentsChatPartService extends Disposable implements IAgentsChatPart chatEnabled = enabled; })); - // Last: what reads the chat part once it exists reads all of it - this._frame.didCreateContent(); + // Last: what reads the chat part once it exists reads all of it, of this instance + this._frame.didCreateContent(this); } retargetComposerFolders(to: URI, from: URI): void { diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContribution.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContribution.ts index 1888122b516185..55c4cea8558107 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContribution.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartContribution.ts @@ -138,7 +138,7 @@ export class EditorWindowChatPartService extends Disposable implements IEditorWi // The sessions of its chats are known while it shows, and while its composers hold drafts, also while chats no longer // open in it store.add(this._sessionsRuntime.enable({ viewIds: [] })); - const existing = () => this._frame.contentCreated.get() ? this._chatPart() : undefined; + const existing = () => this._frame.contentCreated.get() ? this._frame.content.get() : undefined; store.add(this._presenterService.registerComposerHolder({ hasComposer: () => !!existing()?.hasComposer(), closeIdleComposers: async () => existing()?.closeIdleComposers(), @@ -189,10 +189,11 @@ export class EditorWindowChatPartService extends Disposable implements IEditorWi /** The chat part as the presenter of the window's sessions; what reads it without needing it does not create it. */ private _createPresenter(): IEditorWindowSessionsPresenter { - const created = this._frame.contentCreated; - const existing = () => created.get() ? this._chatPart() : undefined; + // Of the frame: reading it as the chat part is being created does not create it again + const { content, contentCreated: created } = this._frame; + const existing = () => created.get() ? content.get() : undefined; return { - activeChat: derived(reader => created.read(reader) ? this._chatPart().activeChat.read(reader) : undefined), + activeChat: derived(reader => created.read(reader) ? content.read(reader)?.activeChat.read(reader) : undefined), openChat: (session, chatResource, options) => this._chatPart().openChat(session, chatResource, options), openComposer: (target, options) => this._chatPart().openComposer(target, options), closeChat: async chatResource => existing()?.closeChat(chatResource), diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartFrame.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartFrame.ts index 4d84d553d7781b..dea215a6be655e 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartFrame.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatPartFrame.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../../base/common/lifecycle.js'; -import { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { derived, IObservable, observableValue } from '../../../../../base/common/observable.js'; import { createDecorator, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ChatHostPart } from '../../../../browser/parts/chat/chatHostPart.js'; +import type { IAgentsChatPartService } from './agentsChatPart.js'; export const IAgentsChatPartFrame = createDecorator('agentsChatPartFrame'); @@ -18,12 +19,15 @@ export interface IAgentsChatPartFrame { readonly _serviceBrand: undefined; readonly part: ChatHostPart; /** - * Whether what the chat part shows, `IAgentsChatPartService`, was created: what reads it without needing it, such as - * what the window's chat is, reads it only then, rather than creating it. + * What the chat part shows, `IAgentsChatPartService`, once it was created: what reads it without needing it, such as what + * the window's chat is, reads this instance, rather than creating it. Not the service: it reads as the service is still + * being created, which reading the service then would create again, as another instance. */ + readonly content: IObservable; + /** Whether {@link content} was created: what reads the chat part reads {@link content} only then. */ readonly contentCreated: IObservable; - /** Called as `IAgentsChatPartService` is created. */ - didCreateContent(): void; + /** Called by `IAgentsChatPartService` as it is created, with itself. */ + didCreateContent(content: IAgentsChatPartService): void; } export class AgentsChatPartFrame extends Disposable implements IAgentsChatPartFrame { @@ -31,15 +35,16 @@ export class AgentsChatPartFrame extends Disposable implements IAgentsChatPartFr declare readonly _serviceBrand: undefined; readonly part: ChatHostPart; - private readonly _contentCreated = observableValue(this, false); - readonly contentCreated: IObservable = this._contentCreated; + private readonly _content = observableValue(this, undefined); + readonly content: IObservable = this._content; + readonly contentCreated: IObservable = derived(this, reader => !!this._content.read(reader)); constructor(@IInstantiationService instantiationService: IInstantiationService) { super(); this.part = this._register(instantiationService.createInstance(ChatHostPart)); } - didCreateContent(): void { - this._contentCreated.set(true, undefined); + didCreateContent(content: IAgentsChatPartService): void { + this._content.set(content, undefined); } } diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts index 097b50fea125fc..f3a7a17d6790d4 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentsChatViewHost.ts @@ -55,8 +55,8 @@ export class AgentsChatViewHost implements IChatViewHost { @INotificationService private readonly _notificationService: INotificationService, @IAgentsChatPartFrame frame: IAgentsChatPartFrame, ) { - // Read once the chat part exists: reading it does not create it - this.shownSession = derived(reader => frame.contentCreated.read(reader) ? this._chatPart.shownSession.read(reader) : undefined); + // Read once the chat part exists, of the frame: reading it does not create it, also not again as it is being created + this.shownSession = derived(reader => frame.contentCreated.read(reader) ? frame.content.read(reader)?.shownSession.read(reader) : undefined); } /** The chat of the chat part's active tab, and whether the user opened it or the chat part restored it. */ diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts index e581d1f8e6c204..e17f8221ed88ae 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatPart.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { DisposableStore, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; -import { autorun, constObservable, IObservable, observableValue, transaction } from '../../../../../../base/common/observable.js'; +import { autorun, constObservable, derived, IObservable, observableValue, transaction } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -43,6 +43,14 @@ import { buildTestSession } from '../../common/testSessionBuilder.js'; import { ISessionsListModelService } from '../../../browser/sessionsListModelService.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; +import { ServiceCollection } from '../../../../../../platform/instantiation/common/serviceCollection.js'; +import { SyncDescriptor } from '../../../../../../platform/instantiation/common/descriptors.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IViewsService } from '../../../../../services/views/common/viewsService.js'; +import { IChatWidgetService } from '../../../../chat/browser/chat.js'; +import { ChatViewHostService } from '../../../../chat/browser/widgetHosts/viewPane/chatViewHostService.js'; +import { AgentsChatViewHost } from '../../../browser/agentsView/agentsChatViewHost.js'; +import { IAgentsViewProvidersService } from '../../../browser/agentsView/agentsViewProviders.js'; /** An editor group that keeps its editors in memory, as the chat part's groups do, without rendering them. */ class FakeGroup { @@ -149,7 +157,7 @@ suite('AgentsChatPartService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - function createService(options?: { readonly stored?: string; readonly visible?: boolean; readonly chatEnabled?: boolean; readonly sessions?: () => readonly ISession[]; readonly newSession?: IObservable; readonly holdOpen?: () => Promise }) { + function createService(options?: { readonly stored?: string; readonly visible?: boolean; readonly chatEnabled?: boolean; readonly sessions?: () => readonly ISession[]; readonly newSession?: IObservable; readonly holdOpen?: () => Promise; readonly delayed?: boolean }) { const store = disposables.add(new DisposableStore()); const instantiationService = workbenchInstantiationService(undefined, store); const contextKeyService = store.add(new ChangingContextKeyService()); @@ -179,7 +187,9 @@ suite('AgentsChatPartService', () => { }, hasFocus: () => false, })); - instantiationService.stub(IAgentsChatPartFrame, upcastPartial({ part: upcastPartial({ setContent: () => toDisposable(() => { }) }), didCreateContent: () => { } })); + // The frame has the chat part once it is created, as the real frame does + const content = observableValue('content', undefined); + instantiationService.stub(IAgentsChatPartFrame, upcastPartial({ part: upcastPartial({ setContent: () => toDisposable(() => { }) }), content, contentCreated: derived(reader => !!content.read(reader)), didCreateContent: created => content.set(created, undefined) })); instantiationService.stub(IEditorGroupsService, upcastPartial({ createEditorPart: () => part as unknown as IContentPart, mainPart: undefined })); instantiationService.stub(IEditorService, upcastPartial({ openEditor: (async (editor: EditorInput, editorOptions: IEditorOptions | undefined, group: FakeGroup) => { @@ -248,11 +258,17 @@ suite('AgentsChatPartService', () => { const host = new EditorWindowMainSessionHost(contextKeyService); instantiationService.stub(ISessionHost, host); - const service = store.add(instantiationService.createInstance(AgentsChatPartService)); + // Delayed, as the editor window registers it: created as something first uses it, such as an open + const scoped = options?.delayed ? store.add(new TestInstantiationService(new ServiceCollection([IAgentsChatPartService, new SyncDescriptor(AgentsChatPartService, [], true)]), false, instantiationService, true)) : undefined; + const service: IAgentsChatPartService = scoped ? scoped.invokeFunction(accessor => accessor.get(IAgentsChatPartService)) : store.add(instantiationService.createInstance(AgentsChatPartService)); const name = (input: EditorInput | null) => input instanceof ChatEditorInput ? (isChatWindowComposerResource(input.chatResource) ? 'composer' : input.chatResource.path) : null; return { service, part, + instantiationService, + /** What the editor window creates with the delayed chat part, which it gets as such. */ + scoped, + store, hide: () => instantiationService.get(IWorkbenchLayoutService).setPartHidden(true, Parts.CHAT_PART), save: () => { storageService.testEmitWillSaveState(WillSaveStateReason.NONE); @@ -399,6 +415,37 @@ suite('AgentsChatPartService', () => { assert.deepStrictEqual({ whileHeld, reports }, { whileHeld: ['other open', 'recalled restore'], reports: ['other open', 'recalled restore'] }); }); + test('the Chat view\'s hosts report the chat of the active tab as the user opens and picks tabs, also as the chat part is created as its first chat opens, rather than as the host first reads it', async () => { + const first = buildTestSession({ id: 'first', title: 'First' }); + const second = buildTestSession({ id: 'second', title: 'Second' }); + const firstChat = first.mainChat.chat.resource; + const secondChat = second.mainChat.chat.resource; + const { service, part, instantiationService, scoped, store } = createService({ sessions: () => [first.session, second.session], delayed: true }); + instantiationService.stub(IViewsService, upcastPartial({ onDidChangeViewVisibility: Event.None, getViewWithId: () => null })); + instantiationService.stub(IChatWidgetService, upcastPartial({})); + instantiationService.stub(IAgentsViewProvidersService, upcastPartial({})); + const hosts = store.add(instantiationService.createInstance(ChatViewHostService)); + // As the editor window registers its host, before the chat part exists + store.add(hosts.registerHost(scoped!.createInstance(AgentsChatViewHost, constObservable(true)))); + const reports: string[] = []; + store.add(autorun(reader => { + const value = hosts.shownSession.read(reader); + if (value) { + reports.push(`${isEqual(value.resource, firstChat) ? 'first' : isEqual(value.resource, secondChat) ? 'second' : value.resource.toString()} ${value.reason}`); + } + })); + + await service.openChat(first.session, firstChat); + await service.openChat(second.session, secondChat); + await timeout(0); + // The user picks the first tab again + const firstTab = part.activeGroup.editors.find(editor => editor instanceof ChatEditorInput && isEqual(editor.chatResource, firstChat))!; + part.activeGroup.open(firstTab); + await timeout(0); + + assert.deepStrictEqual(reports, ['first open', 'second open', 'first open']); + }); + test('its composers that aim at the folder their host aimed them at follow it to another folder, also the active one a new session reuses', async () => { const { service, composerFolders } = createService(); diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts index b0be70fb28a69c..8edb45d66637ee 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentsChatViewHost.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; import { isCancellationError } from '../../../../../../base/common/errors.js'; import { toDisposable } from '../../../../../../base/common/lifecycle.js'; -import { constObservable, IObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { constObservable, derived, IObservable, observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -37,7 +37,8 @@ suite('AgentsChatViewHost', () => { readonly sessionsManagementService?: Partial; readonly widgets?: IChatWidget[]; readonly providersAcquired?: () => void; - readonly contentCreated?: IObservable; + /** The chat part once created, as its frame has it: by default `chatPart`. */ + readonly content?: (chatPart: IAgentsChatPartService) => IObservable; readonly chatService?: Partial; readonly ownsNewChat?: boolean; } @@ -54,6 +55,7 @@ suite('AgentsChatViewHost', () => { isShowing: () => false, ...options.chatPart, }); + const content = options.content?.(chatPart) ?? constObservable(chatPart); const host = new AgentsChatViewHost( constObservable(options.ownsNewChat ?? true), chatPart, @@ -78,7 +80,7 @@ suite('AgentsChatViewHost', () => { ...options.chatService, }), upcastPartial({ info: message => { calls.push(`info ${message}`); return undefined!; } }), - upcastPartial({ contentCreated: options.contentCreated ?? constObservable(true) }), + upcastPartial({ content, contentCreated: derived(reader => !!content.read(reader)) }), ); return { host, calls, composerWidget }; } @@ -339,13 +341,14 @@ suite('AgentsChatViewHost', () => { }); test('reports the chat of the chat part\'s active tab only once the chat part exists, without creating it', () => { - const contentCreated = observableValue('contentCreated', false); + const content = observableValue('content', undefined); const shown: IChatViewHostShownSession = { resource: URI.parse('agent-host-copilot:/session-1'), reason: 'restore' }; const read: string[] = []; - const { host } = createHost({ contentCreated, chatPart: { get shownSession() { read.push('shownSession'); return constObservable(shown); } } }); + let created: IAgentsChatPartService | undefined; + const { host } = createHost({ content: chatPart => { created = chatPart; return content; }, chatPart: { get shownSession() { read.push('shownSession'); return constObservable(shown); } } }); const before = host.shownSession.get(); - contentCreated.set(true, undefined); + content.set(created, undefined); assert.deepStrictEqual({ before, after: host.shownSession.get(), read }, { before: undefined, after: shown, read: ['shownSession'] }); }); From 5f20e3af53a9ace3b5d9f7442d7903e6b8bc78ff Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 12:59:10 +0100 Subject: [PATCH 114/165] Sessions: a composer's picker of a window's folder and its worktrees shows them by the worktree icon, and its chip by its icon only, but New Worktree as Worktree Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/test/browser/newChatWidget.test.ts | 6 +- .../browser/sessionWorkspacePicker.test.ts | 42 +++++++++++++ .../sessions/browser/chat/newChatWidget.ts | 2 +- .../browser/chat/sessionWorkspacePicker.ts | 61 ++++++++++++++++--- 4 files changed, 100 insertions(+), 11 deletions(-) diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts index 58e4364c743376..41e5b6e0819883 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts @@ -424,7 +424,8 @@ interface IRenderWorkspacePickerHarness extends IRenderSessionTypePickerHarness readonly _workspacePicker: { readonly selectedFolderUri: URI | undefined; readonly onDidChangeSelection: Event; - renderCategoryTriggers(container: HTMLElement, triggers: readonly { readonly label?: string; readonly tooltip?: string; readonly icon?: { readonly id: string }; readonly attachesContext?: boolean; readonly clearable?: IObservable }[]): HTMLElement; + getSelectedLocationDescription?(): string | undefined; + renderCategoryTriggers(container: HTMLElement, triggers: readonly { readonly label?: string; readonly tooltip?: string | (() => string); readonly icon?: { readonly id: string }; readonly attachesContext?: boolean; readonly clearable?: IObservable }[]): HTMLElement; }; _renderSessionTypePicker(container: HTMLElement, isQuickChat: boolean): void; _renderPlaceRow(container: HTMLElement, chipRow: HTMLElement): IDisposable; @@ -1757,6 +1758,7 @@ suite('NewChatWidget', () => { _workspacePicker: { selectedFolderUri: undefined, onDidChangeSelection: Event.None, + getSelectedLocationDescription: () => undefined, renderCategoryTriggers: (target, triggers) => { workspaceVisibility.push(pickerVisibility.visibility.get().workspace); const row = document.createElement('div'); @@ -1767,7 +1769,7 @@ suite('NewChatWidget', () => { item.tabIndex = 0; item.textContent = trigger.label ?? 'More'; row.appendChild(item); - workspaceTriggers.push({ tooltip: trigger.tooltip, icon: trigger.icon?.id, attachesContext: trigger.attachesContext }); + workspaceTriggers.push({ tooltip: typeof trigger.tooltip === 'function' ? trigger.tooltip() : trigger.tooltip, icon: trigger.icon?.id, attachesContext: trigger.attachesContext }); } return row; }, diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index 35780b99d4e08c..5dc4cdf58b4269 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -8996,6 +8996,48 @@ suite('WorkspacePicker - Allowed folders', () => { }); }); + test('with a window\'s folder and its worktrees, shows New Worktree and the worktrees by the worktree icon, and its chip by its icon only, naming it for screen readers and on hover, but New Worktree as Worktree, also from a worktree', async () => { + const newWorktree = { selected: false }; + const { picker } = createPicker(['/local/vscode', '/local/vscode.worktrees/a'], undefined, { + getAllowedFolderPresentation: folderUri => folderUri.path === '/local/vscode' + ? { kind: 'folder' } + : { label: 'a', description: 'feature-a', icon: Codicon.gitBranchCheckout, kind: 'worktree' }, + newWorktree: { isAvailable: () => true, isSelected: () => newWorktree.selected, setSelected: selected => { newWorktree.selected = selected; } }, + }); + picker.setSelectedWorkspace(URI.file('/local/vscode'), { fireEvent: false }); + const container = document.createElement('div'); + picker.renderCategoryTriggers(container, [{ label: 'Workspace', ariaLabel: 'Choose a workspace', icon: Codicon.project, reflectsWorkspace: true, attachesContext: false }]); + const trigger = container.querySelector('.sessions-workspace-picker-trigger [role="button"]')!; + const iconOf = (element: Element | null) => [...element?.classList ?? []].find(name => name.startsWith('codicon-') && name !== 'codicon-chevron-down-compact')?.slice('codicon-'.length); + const chip = () => ({ + icon: iconOf(trigger.querySelector('.codicon:not(.sessions-chat-dropdown-chevron)')), + label: trigger.querySelector('.sessions-chat-dropdown-label')?.textContent ?? null, + ariaLabel: trigger.getAttribute('aria-label'), + hover: picker.getSelectedLocationDescription() ?? null, + }); + const items = () => picker.getItems().filter(item => item.kind !== ActionListItemKind.Separator).map(item => `${item.labelIcon?.id} ${item.label}${item.item?.checked ? ' *' : ''}`); + + const atFolder = { items: items(), chip: chip() }; + await picker.dispatchItem(picker.getItems().find(item => item.label === 'New Worktree')!.item!); + const inNewWorktree = chip(); + await picker.dispatchFolder(URI.file('/local/vscode.worktrees/a'), 'local-1'); + const atWorktree = { items: items(), chip: chip() }; + await picker.dispatchItem(picker.getItems().find(item => item.label === 'New Worktree')!.item!); + + assert.deepStrictEqual({ atFolder, inNewWorktree, atWorktree, fromWorktree: { chip: chip(), selected: newWorktree.selected } }, { + atFolder: { + items: ['worktree New Worktree', 'folder local/vscode *', 'worktree a'], + chip: { icon: 'folder', label: null, ariaLabel: 'Folder: local/vscode', hover: 'Folder: local/vscode' }, + }, + inNewWorktree: { icon: 'worktree', label: 'Worktree', ariaLabel: 'New worktree of local/vscode', hover: 'New worktree of local/vscode' }, + atWorktree: { + items: ['worktree New Worktree', 'folder local/vscode', 'worktree a *'], + chip: { icon: 'worktree', label: null, ariaLabel: 'Worktree: a', hover: 'Worktree: a' }, + }, + fromWorktree: { chip: { icon: 'worktree', label: 'Worktree', ariaLabel: 'New worktree of local/vscode', hover: 'New worktree of local/vscode' }, selected: true }, + }); + }); + test('a selected folder that leaves the window gives way to the window\'s first folder', () => { const { picker, allowedFolders } = createPicker(['/local/a', '/local/b']); const selections: string[] = []; diff --git a/src/vs/workbench/contrib/sessions/browser/chat/newChatWidget.ts b/src/vs/workbench/contrib/sessions/browser/chat/newChatWidget.ts index 9e0051db6dedaa..61db558134aa6e 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/newChatWidget.ts +++ b/src/vs/workbench/contrib/sessions/browser/chat/newChatWidget.ts @@ -2200,7 +2200,7 @@ export class NewChatWidget extends Disposable { const workspaceTrigger: IWorkspacePickerTrigger = { label: selectsRepository ? localize('newSessionWorkspacePicker.repository', "Select Repository") : localize('newSessionWorkspacePicker.workspace', "Workspace"), ariaLabel: selectsRepository ? localize('newSessionWorkspacePicker.repositoryAriaLabel', "Choose a repository for the new session") : localize('newSessionWorkspacePicker.workspaceAriaLabel', "Choose a workspace for the new session"), - tooltip: localize('newSessionWorkspacePicker.workspaceTooltip', "Project for the agent to work on"), + tooltip: () => this._workspacePicker.getSelectedLocationDescription() ?? localize('newSessionWorkspacePicker.workspaceTooltip', "Project for the agent to work on"), focusCommand: { id: FOCUS_NEW_SESSION_WORKSPACE_PICKER_COMMAND_ID, when: FOCUS_NEW_SESSION_WORKSPACE_PICKER_WHEN, diff --git a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts index 6f0211b22f2123..f118f07f225fca 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts +++ b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts @@ -42,7 +42,6 @@ import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels. import { ThemeIcon } from '../../../../../base/common/themables.js'; import { IFolderLocation } from '../../../../../platform/workspace/common/folderLocations.js'; import { FolderLocationKind, getCurrentFolderLocationDescription, getNewWorktreeLabel, groupFolderLocations } from '../../../../services/workspaces/common/folderLocationSections.js'; -import { SESSION_WORKTREE_ICON } from '../../common/sessionWorktreeIcon.js'; import { IGitHubInfo, isActiveSessionStatus, ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_GITHUB, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../../services/sessions/common/session.js'; import { ISessionsProvidersService } from '../sessionsProvidersService.js'; import { cloneRepository } from '../cloneRepository.js'; @@ -83,6 +82,12 @@ type DevContainerSampleSelectedClassification = { const FILTER_THRESHOLD = 10; +/** + * The icon of a worktree in a composer's folder picker: New Worktree, each worktree of the window's folder, and the + * picker's chip at one. The Sessions list keeps its own icon for its Worktree groups. + */ +const WORKSPACE_PICKER_WORKTREE_ICON = Codicon.worktree; + /** How workspace picker rows are laid out: the folder name, then its location as a description that truncates first. */ export const WORKSPACE_PICKER_LIST_OPTIONS = { className: 'sessions-new-chat-picker-list', @@ -1749,9 +1754,9 @@ export class WorkspacePicker extends Disposable { private _getWorkspaceIcon(workspace: ISessionWorkspace): ThemeIcon { const folderUri = workspace.folders[0]?.root; if (folderUri && this._isSelectedFolder(folderUri) && this._isNewWorktreeSelected()) { - return SESSION_WORKTREE_ICON; + return WORKSPACE_PICKER_WORKTREE_ICON; } - const presentationIcon = folderUri && this.options.getAllowedFolderPresentation?.(folderUri)?.icon; + const presentationIcon = folderUri && this._getAllowedFolderIcon(this.options.getAllowedFolderPresentation?.(folderUri)); if (presentationIcon) { return presentationIcon; } @@ -2321,7 +2326,7 @@ export class WorkspacePicker extends Disposable { label: getNewWorktreeLabel(), description: undefined, ariaDescription: localize('workspacePicker.newWorktreeDescription', "Start the session in a new worktree of the selected folder."), - group: { title: '', icon: SESSION_WORKTREE_ICON }, + group: { title: '', icon: WORKSPACE_PICKER_WORKTREE_ICON }, item: { id: 'workspacePicker.newWorktree', checked: newWorktreeSelected || undefined, @@ -2459,7 +2464,8 @@ export class WorkspacePicker extends Disposable { && this._getCurrentRepositoryId() === undefined; contents.slot.toggleAttribute('hidden', hideForSelectedWorkspace || hideForMissingWorkspace || hideForMissingGitHubRepository); trigger.classList.toggle('selected', noWorkspaceSelected || (reflectsWorkspace && workspace !== undefined) || isSelectedCategory || badgeCount > 0 || relatedGitHubInfo !== undefined); - const workspaceIcon = workspace ? this._getWorkspaceIcon(workspace) : undefined; + const chip = reflectsWorkspace && workspace ? this._getLocationChip(workspace, this._selectedResolved?.providerId) : undefined; + const workspaceIcon = chip?.icon ?? (workspace ? this._getWorkspaceIcon(workspace) : undefined); const icon = noWorkspaceSelected ? getNoWorkspaceChoicePresentation(undefined).icon : (reflectsWorkspace ? workspaceIcon : undefined) @@ -2490,12 +2496,17 @@ export class WorkspacePicker extends Disposable { : label && label !== emptyLabel ? localize('workspacePicker.categorySelectionAriaLabel', "{0}: {1}", options.label ?? options.ariaLabel, label) : options.ariaLabel); + if (chip && badgeCount === 0) { + // By its icon only, but for New Worktree: it names the folder or worktree for screen readers + trigger.setAttribute('aria-label', chip.description); + } if (reflectsWorkspace) { this._setTriggerReadOnly(trigger, readOnly); } - if (label) { + const shownLabel = chip ? chip.label : label; + if (shownLabel) { contents.label ??= dom.append(trigger, dom.$('span.sessions-chat-dropdown-label')); - contents.label.textContent = label; + contents.label.textContent = shownLabel; } else { contents.label?.remove(); contents.label = undefined; @@ -2918,7 +2929,7 @@ export class WorkspacePicker extends Disposable { description: current ? getCurrentFolderLocationDescription() : presentation?.label !== undefined ? presentation.description : resolved.workspace.description, - group: { title: '', icon: presentation?.icon ?? this._getWorkspaceIcon(resolved.workspace) }, + group: { title: '', icon: this._getAllowedFolderIcon(presentation) ?? this._getWorkspaceIcon(resolved.workspace) }, disabled: this._isProviderUnavailable(resolved.providerId), item: { id: `workspacePicker.workspace.${this.uriIdentityService.extUri.getComparisonKey(folderUri)}`, @@ -2934,6 +2945,40 @@ export class WorkspacePicker extends Disposable { return items; } + /** The icon of an allowed folder as the picker shows it: a worktree's is the picker's worktree icon. */ + private _getAllowedFolderIcon(presentation: IAllowedFolderPresentation | undefined): ThemeIcon | undefined { + return presentation?.kind === 'worktree' ? WORKSPACE_PICKER_WORKTREE_ICON : presentation?.icon; + } + + /** + * The chip of a picker whose allowed folders are a window's folder and its worktrees (with a {@link IAllowedFolderPresentation.kind}): + * by its icon only, a folder or a worktree, but New Worktree by its name, with what it is for screen readers and on hover. + */ + private _getLocationChip(workspace: ISessionWorkspace, providerId: string | undefined): { readonly icon: ThemeIcon; readonly label: string | undefined; readonly description: string } | undefined { + const folderUri = workspace.folders[0]?.root; + const presentation = folderUri && this._getAllowedFolders() ? this.options.getAllowedFolderPresentation?.(folderUri) : undefined; + if (!folderUri || !presentation?.kind) { + return undefined; + } + const name = presentation.label ?? this._getFolderWorkspaceLabel(workspace, providerId); + if (this._isSelectedFolder(folderUri) && this._isNewWorktreeSelected()) { + // A new worktree of the window's folder, also from one of its worktrees + const primary = presentation.kind === 'worktree' ? this._getAllowedFolders()?.find(candidate => this.options.getAllowedFolderPresentation?.(candidate)?.kind === 'folder') : undefined; + const primaryResolved = primary ? this._resolveFolder(primary) : undefined; + const of = primary && primaryResolved ? this.options.getAllowedFolderPresentation?.(primary)?.label ?? this._getFolderWorkspaceLabel(primaryResolved.workspace, primaryResolved.providerId) : name; + return { icon: WORKSPACE_PICKER_WORKTREE_ICON, label: localize('workspacePicker.newWorktreeChip', "Worktree"), description: localize('workspacePicker.newWorktreeChipDescription', "New worktree of {0}", of) }; + } + return presentation.kind === 'worktree' + ? { icon: WORKSPACE_PICKER_WORKTREE_ICON, label: undefined, description: localize('workspacePicker.worktreeChipDescription', "Worktree: {0}", name) } + : { icon: presentation.icon ?? Codicon.folder, label: undefined, description: localize('workspacePicker.folderChipDescription', "Folder: {0}", name) }; + } + + /** What the chip of a picker restricted to a window's folder and its worktrees shows, by name: for its hover. */ + getSelectedLocationDescription(): string | undefined { + const selected = this._getNoWorkspaceOption()?.isSelected ? undefined : this._selectedResolved; + return selected && this._getLocationChip(selected.workspace, selected.providerId)?.description; + } + /** Whether the selected folder's session works in a new worktree of it. */ private _isNewWorktreeSelected(): boolean { return !!this._selectedFolderUri && !this.isNoWorkspaceSelected() && !!this.options.newWorktree?.isAvailable() && this.options.newWorktree.isSelected(); From 44dc0b5a68a5dbf8b9970d368120681135a67062 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 13:10:05 +0100 Subject: [PATCH 115/165] Sessions: a composer's picker at the location a window's folder moved to shows it selected, also by another form of its path, so New Worktree sticks there Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/sessionWorkspacePicker.test.ts | 28 +++++++++++++++-- .../browser/chat/sessionWorkspacePicker.ts | 30 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index 5dc4cdf58b4269..0e74e513cd74e8 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -8808,9 +8808,11 @@ suite('WorkspacePicker - Allowed folders', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); /** A picker of an editor window's composer, which offers only the window's folders. */ - function createPicker(folders: readonly string[], noWorkspace?: { selected: boolean }, options?: Pick) { + function createPicker(folders: readonly string[], noWorkspace?: { selected: boolean }, options?: Pick & { readonly resolves?: (folderUri: URI) => boolean }) { const providersService = store.add(new MockSessionsProvidersService()); - providersService.setProviders([createMockProvider('local-1', { group: SESSION_WORKSPACE_GROUP_LOCAL })]); + const provider = createMockProvider('local-1', { group: SESSION_WORKSPACE_GROUP_LOCAL }); + const resolveWorkspace = provider.resolveWorkspace; + providersService.setProviders([{ ...provider, resolveWorkspace: (folderUri: URI) => options?.resolves && !options.resolves(folderUri) ? undefined : resolveWorkspace(folderUri) }]); const allowedFolders = observableValue('allowedFolders', folders.map(folder => URI.file(folder))); let shows = 0; const actionWidgetService = upcastPartial({ isVisible: false, hide: () => { }, show: () => { shows++; }, updateItems: () => { } }); @@ -9038,6 +9040,28 @@ suite('WorkspacePicker - Allowed folders', () => { }); }); + test('at a worktree selected by another form of its path, which no provider resolves, shows the worktree the window\'s folder is at checked, by the worktree icon, and New Worktree then sticks', async () => { + const newWorktree = { selected: false }; + const { picker } = createPicker(['/local/vscode.worktrees/a', '/local/vscode', '/private/local/vscode.worktrees/a'], undefined, { + // The window's folder is at the worktree, also by the other form of its path + getAllowedFolderPresentation: folderUri => folderUri.path === '/local/vscode' ? { kind: 'folder' } : { label: 'a', kind: 'worktree', current: true }, + newWorktree: { isAvailable: () => true, isSelected: () => newWorktree.selected, setSelected: selected => { newWorktree.selected = selected; } }, + resolves: folderUri => !folderUri.path.startsWith('/private/'), + }); + picker.setSelectedWorkspace(URI.file('/private/local/vscode.worktrees/a'), { fireEvent: false }); + const container = document.createElement('div'); + picker.renderCategoryTriggers(container, [{ label: 'Workspace', ariaLabel: 'Choose a workspace', icon: Codicon.project, reflectsWorkspace: true, attachesContext: false }]); + const trigger = container.querySelector('.sessions-workspace-picker-trigger [role="button"]')!; + const checked = () => picker.getItems().filter(item => item.item?.checked).map(item => item.item?.folderUri?.path ?? item.label); + const atWorktree = { checked: checked(), ariaLabel: trigger.getAttribute('aria-label'), worktreeIcon: !!trigger.querySelector('.codicon-worktree') }; + await picker.dispatchItem(picker.getItems().find(item => item.label === 'New Worktree')!.item!); + + assert.deepStrictEqual({ atWorktree, inNewWorktree: { checked: checked(), label: trigger.querySelector('.sessions-chat-dropdown-label')?.textContent } }, { + atWorktree: { checked: ['/local/vscode.worktrees/a'], ariaLabel: 'Worktree: a', worktreeIcon: true }, + inNewWorktree: { checked: ['New Worktree'], label: 'Worktree' }, + }); + }); + test('a selected folder that leaves the window gives way to the window\'s first folder', () => { const { picker, allowedFolders } = createPicker(['/local/a', '/local/b']); const selections: string[] = []; diff --git a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts index f118f07f225fca..c8fde7bae21afc 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts +++ b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts @@ -568,6 +568,7 @@ export class WorkspacePicker extends Disposable { if (allowed && this._selectedFolderUri && !this._isAllowedFolder(this._selectedFolderUri)) { this.setSelectedWorkspace(allowed[0], { persist: false }); } + this._resolveSelectedAllowedFolder(); this._updateTriggerLabel(); })); } @@ -2427,6 +2428,7 @@ export class WorkspacePicker extends Disposable { } if (options) { const reflectsWorkspace = options.reflectsWorkspace === true; + this._resolveSelectedAllowedFolder(); const noWorkspaceSelected = reflectsWorkspace && this._getNoWorkspaceOption()?.isSelected === true; const workspace = noWorkspaceSelected ? undefined : this._selectedResolved?.workspace; // Restricted to its allowed folders, it has no No Workspace to clear to @@ -2878,6 +2880,7 @@ export class WorkspacePicker extends Disposable { if (!allowedFolders) { return undefined; } + this._resolveSelectedAllowedFolder(); const folders = this._buildAllowedFolderItems(allowedFolders); if (!folders.some(folder => folder.kind)) { return this._withSelectionMarks(this._withNewWorktreeItem(this._withNoWorkspaceItem(folders.map(folder => folder.item)))); @@ -2979,6 +2982,33 @@ export class WorkspacePicker extends Disposable { return selected && this._getLocationChip(selected.workspace, selected.providerId)?.description; } + /** + * Resolves the selected folder of a picker restricted to its allowed folders, where no provider resolved it as it was + * selected, such as the location a window's folder moved to: the picker shows it, checked, once a provider does. + */ + private _resolveSelectedAllowedFolder(): void { + if (!this._selectedFolderUri || this._selectedResolved || this._noWorkspaceSelected || !this._getAllowedFolders()) { + return; + } + let resolved = this._resolveFolder(this._selectedFolderUri); + if (!resolved && this.options.getAllowedFolderPresentation?.(this._selectedFolderUri)?.current) { + // Where the window's folder is now, by another form of its path, such as /private/tmp of /tmp: the allowed folder + // that the window's folder is at, and that resolves + for (const folderUri of this._getAllowedFolders() ?? []) { + const candidate = this.options.getAllowedFolderPresentation?.(folderUri)?.current ? this._resolveFolder(folderUri) : undefined; + if (candidate) { + this._selectedFolderUri = folderUri; + resolved = candidate; + break; + } + } + } + if (resolved) { + this._selectedResolved = resolved; + this._watchSelectedGitHubInfo(); + } + } + /** Whether the selected folder's session works in a new worktree of it. */ private _isNewWorktreeSelected(): boolean { return !!this._selectedFolderUri && !this.isNoWorkspaceSelected() && !!this.options.newWorktree?.isAvailable() && this.options.newWorktree.isSelected(); From 99ac12eb35e25d1e299bae1d32d055f838e06efe Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 13:12:19 +0100 Subject: [PATCH 116/165] Worktree support: the Chat view's folder chip shows the folder or worktree by its icon only, but New Worktree as Worktree, naming it for screen readers and on hover Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostFolderPickerActionItem.ts | 20 +++++++++---- .../agentHostWorktreeFolderChoices.ts | 29 +++++++++++++++---- .../agentHostWorktreeFolderChoices.test.ts | 15 ++++++---- 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts index 3ab70300c092d5..92a40dd6ce7840 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts @@ -169,13 +169,16 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt protected override renderLabel(element: HTMLElement): IDisposable | null { const selected = this._selectedFolder(); const folder = selected && this._workspaceContextService.getWorkspace().folders.find(f => f.uri.toString() === selected.toString()); - const label = selected && this._worktree?.isActive() ? this._worktree.getLabel(selected) : folder ? folder.name : (selected ? basename(selected) : localize('agentHost.selectFolder', "Folder")); + // With worktree support, by its icon only, a folder or a worktree, but New Worktree by its name + const chip = selected && this._worktree?.isActive() ? this._worktree.getChip(selected) : undefined; + const label = chip ? chip.description : folder ? folder.name : (selected ? basename(selected) : localize('agentHost.selectFolder', "Folder")); + const shownLabel = chip ? chip.label : label; const compact = this.pickerOptions.compact.get(); - element.classList.toggle('icon-only', compact); + element.classList.toggle('icon-only', compact || !shownLabel); dom.reset( element, - ...renderLabelWithIcons(`$(folder-compact)`), - ...(!compact ? [dom.$('span.chat-input-picker-label', undefined, label)] : []), + ...renderLabelWithIcons(chip?.icon === Codicon.worktree ? `$(${Codicon.worktree.id})` : `$(folder-compact)`), + ...(!compact && shownLabel ? [dom.$('span.chat-input-picker-label', undefined, shownLabel)] : []), ); // Set the aria label after the visible text is in place: the base class // derives it from `element.textContent`, so labeling first would lag one @@ -218,7 +221,14 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt const md = new MarkdownString('', { supportThemeIcons: true }); const fallbackLines: string[] = []; - md.appendMarkdown(`$(${Codicon.folder.id}) `); + // What the chip shows by its icon only, by name + const chip = this._worktree.isActive() ? this._worktree.getChip(selected) : undefined; + if (chip) { + md.appendText(chip.description); + md.appendMarkdown('\n\n'); + fallbackLines.push(chip.description); + } + md.appendMarkdown(`$(${(chip?.icon ?? Codicon.folder).id}) `); md.appendText(selected.fsPath); fallbackLines.push(selected.fsPath); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts index 6c4ac311309fa6..3aae861779193a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts @@ -10,6 +10,7 @@ import { onUnexpectedError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { basename, extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; import { getSessionIsolationProperty } from '../../../../../../platform/agentHost/common/sessionConfigProperties.js'; @@ -27,6 +28,9 @@ import { getLocalAgentHostSessionProvider } from './agentHostSessionUri.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; /** What the folder chip shows for a folder, and which one it is. */ +/** The icon of a worktree in the folder chip: New Worktree, the window's folder's worktrees, and the chip at one. */ +const WORKTREE_ICON = Codicon.worktree; + interface IFolderChoice { readonly uri: URI; readonly label: string; @@ -106,10 +110,23 @@ export class AgentHostWorktreeFolderChoices extends Disposable { return !!isolation && isolation.values[isolation.key] === 'worktree'; } - /** The label of the chip for the selected `folder`: by its name, or as a new worktree of it. */ - getLabel(folder: URI): string { - const name = this._choices().find(choice => extUriBiasedIgnorePathCase.isEqual(choice.uri, folder))?.label ?? basename(folder); - return this.isNewWorktreeSelected() ? localize('agentHost.folderPicker.newWorktree', "{0} - New Worktree", name) : name; + /** + * The chip for the selected `folder`: by its icon only, a folder or a worktree, but New Worktree by its name, with what it + * is for screen readers and on hover. + */ + getChip(folder: URI): { readonly icon: ThemeIcon; readonly label: string | undefined; readonly description: string } { + const choices = this._choices(); + const choice = choices.find(candidate => extUriBiasedIgnorePathCase.isEqual(candidate.uri, folder)); + const primary = choices.find(candidate => candidate.kind === 'folder'); + if (this.isNewWorktreeSelected()) { + return { icon: WORKTREE_ICON, label: localize('agentHost.folderPicker.newWorktreeChip', "Worktree"), description: localize('agentHost.folderPicker.newWorktreeChipDescription', "New worktree of {0}", primary?.label ?? basename(folder)) }; + } + const name = choice?.label ?? basename(folder); + // A folder that is not the window's own, such as the worktree a started session works in, is a worktree of it + const worktree = choice ? choice.kind === 'worktree' : !!primary && !extUriBiasedIgnorePathCase.isEqual(primary.uri, folder); + return worktree + ? { icon: WORKTREE_ICON, label: undefined, description: localize('agentHost.folderPicker.worktreeChipDescription', "Worktree: {0}", name) } + : { icon: Codicon.folder, label: undefined, description: localize('agentHost.folderPicker.folderChipDescription', "Folder: {0}", name) }; } /** The chip's choices, `selected` checked, unless the session is to work in a new worktree. */ @@ -123,7 +140,7 @@ export class AgentHostWorktreeFolderChoices extends Disposable { label: getNewWorktreeLabel(), tooltip: localize('agentHost.folderPicker.newWorktreeDescription', "Start the session in a new worktree of the selected folder."), checked: newWorktreeSelected, - icon: Codicon.gitBranchCheckout, + icon: WORKTREE_ICON, enabled: true, category: { label: '', order: 0 }, run: async () => { @@ -141,7 +158,7 @@ export class AgentHostWorktreeFolderChoices extends Disposable { label: choice.label, description: choice.current ? getCurrentFolderLocationDescription() : undefined, checked: !newWorktreeSelected && !!selected && extUriBiasedIgnorePathCase.isEqual(selected, choice.uri), - icon: choice.kind === 'worktree' ? Codicon.gitBranchCheckout : Codicon.folder, + icon: choice.kind === 'worktree' ? WORKTREE_ICON : Codicon.folder, enabled: true, tooltip: choice.uri.fsPath, category: { label: section.title, order: index + 1, showHeader: true }, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts index e7febd9dd60e5b..4ecdc6298ef950 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts @@ -82,8 +82,9 @@ suite('AgentHostWorktreeFolderChoices', () => { // A worktree that no session lists any more, such as one whose sessions are archived const atUnlisted = await createChoices({ enabled: true, at: other, listed: [feature] }); - assert.deepStrictEqual({ atWorktree: atWorktree.items(other), atUnlisted: atUnlisted.items(other), atFolder: atFolder.items(primary) }, { + assert.deepStrictEqual({ atWorktree: atWorktree.items(other), icons: atWorktree.actions(other).map(action => action.icon?.id), atUnlisted: atUnlisted.items(other), atFolder: atFolder.items(primary) }, { atWorktree: ['New Worktree', '[Folder] vscode', '[Worktrees] other (Current) *', '[Worktrees] feature'], + icons: ['worktree', 'folder', 'worktree', 'worktree'], atUnlisted: ['New Worktree', '[Folder] vscode', '[Worktrees] other (Current) *', '[Worktrees] feature'], atFolder: ['New Worktree', '[Folder] vscode (Current) *', '[Worktrees] feature', '[Worktrees] other'], }); @@ -91,14 +92,18 @@ suite('AgentHostWorktreeFolderChoices', () => { test('New Worktree has the session work in a new worktree, which the label shows, and picking a folder again works in it', async () => { const { choices, actions, items, changes } = await createChoices({ enabled: true, at: primary }); + const chip = (folder: URI) => { const { icon, label, description } = choices.getChip(folder); return { icon: icon.id, label: label ?? null, description }; }; + const folderChip = chip(primary); await actions(primary).find(action => action.label === 'New Worktree')!.run(); - const inNewWorktree = { items: items(primary), label: choices.getLabel(primary) }; + const inNewWorktree = { items: items(primary), chip: chip(primary) }; await actions(primary).find(action => action.label === 'feature')!.run(); - assert.deepStrictEqual({ inNewWorktree, changes, label: choices.getLabel(feature) }, { - inNewWorktree: { items: ['New Worktree *', '[Folder] vscode (Current)', '[Worktrees] feature', '[Worktrees] other'], label: 'vscode - New Worktree' }, + // By its icon only, but New Worktree by its name; what it is, by name, for screen readers and on hover + assert.deepStrictEqual({ inNewWorktree, changes, folderChip, worktreeChip: chip(feature) }, { + inNewWorktree: { items: ['New Worktree *', '[Folder] vscode (Current)', '[Worktrees] feature', '[Worktrees] other'], chip: { icon: 'worktree', label: 'Worktree', description: 'New worktree of vscode' } }, changes: ['isolation worktree', 'folder /work/vscode.worktrees/feature', 'isolation folder'], - label: 'feature', + folderChip: { icon: 'folder', label: null, description: 'Folder: vscode' }, + worktreeChip: { icon: 'worktree', label: null, description: 'Worktree: feature' }, }); }); From 6ee9348e6b0a31c311dc26af5fc8208f3e8ccf6b Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 13:14:05 +0100 Subject: [PATCH 117/165] Worktree support: test that an existing chat's folder chip shows the worktree its session works in by the worktree icon, also one the chip no longer lists Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostWorktreeFolderChoices.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts index 4ecdc6298ef950..ed83714155a059 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts @@ -99,11 +99,13 @@ suite('AgentHostWorktreeFolderChoices', () => { await actions(primary).find(action => action.label === 'feature')!.run(); // By its icon only, but New Worktree by its name; what it is, by name, for screen readers and on hover - assert.deepStrictEqual({ inNewWorktree, changes, folderChip, worktreeChip: chip(feature) }, { + // An existing chat, whose started session works in a worktree that the chip no longer lists, also shows it as a worktree + assert.deepStrictEqual({ inNewWorktree, changes, folderChip, worktreeChip: chip(feature), unlistedWorktreeChip: chip(URI.file('/work/vscode.worktrees/small-random-change-addition')) }, { inNewWorktree: { items: ['New Worktree *', '[Folder] vscode (Current)', '[Worktrees] feature', '[Worktrees] other'], chip: { icon: 'worktree', label: 'Worktree', description: 'New worktree of vscode' } }, changes: ['isolation worktree', 'folder /work/vscode.worktrees/feature', 'isolation folder'], folderChip: { icon: 'folder', label: null, description: 'Folder: vscode' }, worktreeChip: { icon: 'worktree', label: null, description: 'Worktree: feature' }, + unlistedWorktreeChip: { icon: 'worktree', label: null, description: 'Worktree: small-random-change-addition' }, }); }); From ef6b4b2d64fee27631bfd09520554c12ec4ff7b0 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 13:21:39 +0100 Subject: [PATCH 118/165] Worktree support: the folder chip shows a folder it does not list as a worktree only once git proves it one of the window's folder Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostWorktreeFolderChoices.ts | 31 +++++++++++++++++-- .../agentHostWorktreeFolderChoices.test.ts | 15 +++++++-- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts index 3aae861779193a..755f346404b4f5 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { RunOnceScheduler } from '../../../../../../base/common/async.js'; -import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { onUnexpectedError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; @@ -48,6 +48,8 @@ export class AgentHostWorktreeFolderChoices extends Disposable { /** The folder's worktrees, read ahead of time, as the chip asks for its choices synchronously. */ private _locations: readonly IFolderLocation[] = []; + /** Whether folders it does not list are worktrees of the window's folder, by the window's folder and the folder. */ + private readonly _provenWorktrees = new Map(); private readonly _refresh = this._register(new MutableDisposable()); private readonly _onDidChange = this._register(new Emitter()); @@ -122,8 +124,8 @@ export class AgentHostWorktreeFolderChoices extends Disposable { return { icon: WORKTREE_ICON, label: localize('agentHost.folderPicker.newWorktreeChip', "Worktree"), description: localize('agentHost.folderPicker.newWorktreeChipDescription', "New worktree of {0}", primary?.label ?? basename(folder)) }; } const name = choice?.label ?? basename(folder); - // A folder that is not the window's own, such as the worktree a started session works in, is a worktree of it - const worktree = choice ? choice.kind === 'worktree' : !!primary && !extUriBiasedIgnorePathCase.isEqual(primary.uri, folder); + // A folder it does not list, such as the worktree a started session works in, is a worktree once git proves it one + const worktree = choice ? choice.kind === 'worktree' : this._isProvenWorktree(folder); return worktree ? { icon: WORKTREE_ICON, label: undefined, description: localize('agentHost.folderPicker.worktreeChipDescription', "Worktree: {0}", name) } : { icon: Codicon.folder, label: undefined, description: localize('agentHost.folderPicker.folderChipDescription', "Folder: {0}", name) }; @@ -199,6 +201,29 @@ export class AgentHostWorktreeFolderChoices extends Disposable { ]; } + /** + * Whether `folder` is a worktree of the window's folder, as its location providers resolve it, such as by git's links + * between them: not yet known, it is not, and the chip shows it again once it is. + */ + private _isProvenWorktree(folder: URI): boolean { + const primary = this._workspaceContextService.getWorkspace().uri; + if (!primary) { + return false; + } + const key = `${primary.toString()}\n${folder.toString()}`; + const known = this._provenWorktrees.get(key); + if (known === undefined) { + this._provenWorktrees.set(key, false); + this._folderLocationService.resolveLocation(primary, folder, CancellationToken.None).then(location => { + if (location && !this._store.isDisposed) { + this._provenWorktrees.set(key, true); + this._onDidChange.fire(); + } + }, onUnexpectedError); + } + return known === true; + } + /** The session's isolation property and values, with whether it can work in a new worktree. */ private _isolation(): { readonly key: string; readonly values: Readonly>; readonly worktree: boolean; readonly inPlace: string | undefined } | undefined { const sessionResource = this._sessionResource(); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts index ed83714155a059..7e7e4801759b02 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts @@ -51,6 +51,8 @@ suite('AgentHostWorktreeFolderChoices', () => { canSetLocation: () => true, getLocations: async () => listed.locations.map(uri => upcastPartial({ uri, label: uri.path.split('/').pop() })), getLocation: () => options.at.path === primary.path ? undefined : upcastPartial({ uri: options.at, label: options.at.path.split('/').pop() }), + // As git links them: only the worktrees of the window's folder resolve, not another repository or the folder itself + resolveLocation: async (_primary: URI, location: URI) => location.path.startsWith('/work/vscode.worktrees/') ? upcastPartial({ uri: location }) : undefined, })); instantiationService.stub(IAgentHostNewSessionFolderService, upcastPartial({ getFolder: () => undefined, @@ -99,13 +101,22 @@ suite('AgentHostWorktreeFolderChoices', () => { await actions(primary).find(action => action.label === 'feature')!.run(); // By its icon only, but New Worktree by its name; what it is, by name, for screen readers and on hover - // An existing chat, whose started session works in a worktree that the chip no longer lists, also shows it as a worktree - assert.deepStrictEqual({ inNewWorktree, changes, folderChip, worktreeChip: chip(feature), unlistedWorktreeChip: chip(URI.file('/work/vscode.worktrees/small-random-change-addition')) }, { + // An existing chat, whose started session works in a folder that the chip does not list: a worktree once git proves it + // one; another repository, or the window's folder by another path, is a folder + const unlisted = { worktree: URI.file('/work/vscode.worktrees/small-random-change-addition'), otherRepository: URI.file('/work/another-repo'), primaryAlias: URI.file('/private/work/vscode') }; + const unlistedBeforeProven = chip(unlisted.worktree).icon; + chip(unlisted.otherRepository); + chip(unlisted.primaryAlias); + await new Promise(resolve => setTimeout(resolve, 0)); + assert.deepStrictEqual({ inNewWorktree, changes, folderChip, worktreeChip: chip(feature), unlistedBeforeProven, unlistedWorktreeChip: chip(unlisted.worktree), otherRepositoryChip: chip(unlisted.otherRepository), primaryAliasChip: chip(unlisted.primaryAlias) }, { inNewWorktree: { items: ['New Worktree *', '[Folder] vscode (Current)', '[Worktrees] feature', '[Worktrees] other'], chip: { icon: 'worktree', label: 'Worktree', description: 'New worktree of vscode' } }, changes: ['isolation worktree', 'folder /work/vscode.worktrees/feature', 'isolation folder'], folderChip: { icon: 'folder', label: null, description: 'Folder: vscode' }, worktreeChip: { icon: 'worktree', label: null, description: 'Worktree: feature' }, + unlistedBeforeProven: 'folder', unlistedWorktreeChip: { icon: 'worktree', label: null, description: 'Worktree: small-random-change-addition' }, + otherRepositoryChip: { icon: 'folder', label: null, description: 'Folder: another-repo' }, + primaryAliasChip: { icon: 'folder', label: null, description: 'Folder: vscode' }, }); }); From d3efaf4b8549f50e84e2208aed2b70ae0010f60b Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 13:26:53 +0100 Subject: [PATCH 119/165] Sessions: a composer's picker selects the location a window's folder is at, by another form of its path, as one selection with its events Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/sessionWorkspacePicker.test.ts | 38 +++++++++-- .../browser/chat/sessionWorkspacePicker.ts | 63 ++++++++++++------- 2 files changed, 74 insertions(+), 27 deletions(-) diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index 0e74e513cd74e8..de32543d336aae 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -9040,7 +9040,7 @@ suite('WorkspacePicker - Allowed folders', () => { }); }); - test('at a worktree selected by another form of its path, which no provider resolves, shows the worktree the window\'s folder is at checked, by the worktree icon, and New Worktree then sticks', async () => { + test('at a worktree selected by another form of its path, which no provider resolves, selects the worktree the window\'s folder is at, as one selection with its events, so its row is checked, its chip shows it and New Worktree sticks', async () => { const newWorktree = { selected: false }; const { picker } = createPicker(['/local/vscode.worktrees/a', '/local/vscode', '/private/local/vscode.worktrees/a'], undefined, { // The window's folder is at the worktree, also by the other form of its path @@ -9048,17 +9048,43 @@ suite('WorkspacePicker - Allowed folders', () => { newWorktree: { isAvailable: () => true, isSelected: () => newWorktree.selected, setSelected: selected => { newWorktree.selected = selected; } }, resolves: folderUri => !folderUri.path.startsWith('/private/'), }); - picker.setSelectedWorkspace(URI.file('/private/local/vscode.worktrees/a'), { fireEvent: false }); + const events: string[] = []; + store.add(picker.onDidSelectWorkspace(folderUri => events.push(`select ${folderUri?.path}`))); + store.add(picker.onDidChangeSelection(() => events.push(`change ${picker.selectedFolderUri?.path}`))); + picker.setSelectedWorkspace(URI.file('/private/local/vscode.worktrees/a')); + // Before any trigger renders it, as a composer reads it + const selected = picker.selectedFolderUri?.path; const container = document.createElement('div'); picker.renderCategoryTriggers(container, [{ label: 'Workspace', ariaLabel: 'Choose a workspace', icon: Codicon.project, reflectsWorkspace: true, attachesContext: false }]); const trigger = container.querySelector('.sessions-workspace-picker-trigger [role="button"]')!; const checked = () => picker.getItems().filter(item => item.item?.checked).map(item => item.item?.folderUri?.path ?? item.label); - const atWorktree = { checked: checked(), ariaLabel: trigger.getAttribute('aria-label'), worktreeIcon: !!trigger.querySelector('.codicon-worktree') }; + const atWorktree = { selected, events: [...events], checked: checked(), ariaLabel: trigger.getAttribute('aria-label'), worktreeIcon: !!trigger.querySelector('.codicon-worktree') }; await picker.dispatchItem(picker.getItems().find(item => item.label === 'New Worktree')!.item!); - assert.deepStrictEqual({ atWorktree, inNewWorktree: { checked: checked(), label: trigger.querySelector('.sessions-chat-dropdown-label')?.textContent } }, { - atWorktree: { checked: ['/local/vscode.worktrees/a'], ariaLabel: 'Worktree: a', worktreeIcon: true }, - inNewWorktree: { checked: ['New Worktree'], label: 'Worktree' }, + assert.deepStrictEqual({ atWorktree, inNewWorktree: { checked: checked(), label: trigger.querySelector('.sessions-chat-dropdown-label')?.textContent, selected: picker.selectedFolderUri?.path } }, { + atWorktree: { selected: '/local/vscode.worktrees/a', events: ['change /local/vscode.worktrees/a', 'select /local/vscode.worktrees/a'], checked: ['/local/vscode.worktrees/a'], ariaLabel: 'Worktree: a', worktreeIcon: true }, + inNewWorktree: { checked: ['New Worktree'], label: 'Worktree', selected: '/local/vscode.worktrees/a' }, + }); + }); + + test('a worktree selected by another form of its path before the window\'s folder is known to be there is selected again by the worktree, once, with its events, as the folders tell', () => { + let known = false; + const { picker, allowedFolders } = createPicker(['/local/vscode.worktrees/a', '/local/vscode', '/private/local/vscode.worktrees/a'], undefined, { + getAllowedFolderPresentation: folderUri => folderUri.path === '/local/vscode' ? { kind: 'folder' } : { label: 'a', kind: 'worktree', current: known }, + resolves: folderUri => !folderUri.path.startsWith('/private/'), + }); + picker.setSelectedWorkspace(URI.file('/private/local/vscode.worktrees/a'), { fireEvent: false }); + const before = picker.selectedFolderUri?.path; + const events: string[] = []; + store.add(picker.onDidSelectWorkspace(folderUri => events.push(`select ${folderUri?.path}`))); + store.add(picker.onDidChangeSelection(() => events.push(`change ${picker.selectedFolderUri?.path}`))); + known = true; + allowedFolders.set([...allowedFolders.get()], undefined); + + assert.deepStrictEqual({ before, after: picker.selectedFolderUri?.path, events }, { + before: '/private/local/vscode.worktrees/a', + after: '/local/vscode.worktrees/a', + events: ['change /local/vscode.worktrees/a', 'select /local/vscode.worktrees/a'], }); }); diff --git a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts index c8fde7bae21afc..3376c6502b40dc 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts +++ b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts @@ -568,7 +568,7 @@ export class WorkspacePicker extends Disposable { if (allowed && this._selectedFolderUri && !this._isAllowedFolder(this._selectedFolderUri)) { this.setSelectedWorkspace(allowed[0], { persist: false }); } - this._resolveSelectedAllowedFolder(); + this._reconcileSelectedAllowedFolder(); this._updateTriggerLabel(); })); } @@ -596,7 +596,12 @@ export class WorkspacePicker extends Disposable { if (this._selectedFolderUri) { // Re-resolve in case the previous resolving provider was removed. const reresolved = this._resolveFolder(this._selectedFolderUri); - if (!reresolved) { + const canonical = reresolved ? undefined : this._canonicalAllowedFolder(this._selectedFolderUri); + if (canonical && !this.uriIdentityService.extUri.isEqual(canonical, this._selectedFolderUri)) { + // Restricted to its allowed folders: selected again by the allowed folder the window's folder is at + this._selectedResolved = undefined; + this._reconcileSelectedAllowedFolder(); + } else if (!reresolved) { this._selectedFolderUri = undefined; this._selectedResolved = undefined; this._selectedDevContainerFolderUri = undefined; @@ -1447,6 +1452,7 @@ export class WorkspacePicker extends Disposable { if (!this._isAllowedFolder(folderUri)) { return; } + folderUri = this._canonicalAllowedFolder(folderUri); if (source === NewSessionWorkspacePreselectionSource.User) { this.options.onUserSelection?.(); } @@ -2983,29 +2989,44 @@ export class WorkspacePicker extends Disposable { } /** - * Resolves the selected folder of a picker restricted to its allowed folders, where no provider resolved it as it was - * selected, such as the location a window's folder moved to: the picker shows it, checked, once a provider does. + * The allowed folder to select for `folderUri`: itself, unless no provider resolves it but it is where the window's folder + * is, by another form of its path such as /private/tmp of /tmp: then the allowed folder marked as there that resolves. */ - private _resolveSelectedAllowedFolder(): void { - if (!this._selectedFolderUri || this._selectedResolved || this._noWorkspaceSelected || !this._getAllowedFolders()) { - return; + private _canonicalAllowedFolder(folderUri: URI): URI { + const allowedFolders = this._getAllowedFolders(); + if (!allowedFolders || this._resolveFolder(folderUri) || !this.options.getAllowedFolderPresentation?.(folderUri)?.current) { + return folderUri; } - let resolved = this._resolveFolder(this._selectedFolderUri); - if (!resolved && this.options.getAllowedFolderPresentation?.(this._selectedFolderUri)?.current) { - // Where the window's folder is now, by another form of its path, such as /private/tmp of /tmp: the allowed folder - // that the window's folder is at, and that resolves - for (const folderUri of this._getAllowedFolders() ?? []) { - const candidate = this.options.getAllowedFolderPresentation?.(folderUri)?.current ? this._resolveFolder(folderUri) : undefined; - if (candidate) { - this._selectedFolderUri = folderUri; - resolved = candidate; - break; - } + return allowedFolders.find(candidate => this.options.getAllowedFolderPresentation?.(candidate)?.current && !!this._resolveFolder(candidate)) ?? folderUri; + } + + /** Resolves the selected allowed folder that no provider resolved as it was selected, as it renders: the same folder, silently. */ + private _resolveSelectedAllowedFolder(): void { + if (this._selectedFolderUri && !this._selectedResolved && !this._noWorkspaceSelected && this._getAllowedFolders()) { + const resolved = this._resolveFolder(this._selectedFolderUri); + if (resolved) { + this._selectedResolved = resolved; + this._watchSelectedGitHubInfo(); } } - if (resolved) { - this._selectedResolved = resolved; - this._watchSelectedGitHubInfo(); + } + + /** + * As the allowed folders or the providers change: the selected allowed folder that does not resolve is selected again by + * its canonical allowed folder ({@link _canonicalAllowedFolder}), as any selection is, with its events, once. + */ + private _reconcileSelectedAllowedFolder(): void { + const selected = this._selectedFolderUri; + if (!selected || this._selectedResolved || this._noWorkspaceSelected || !this._getAllowedFolders()) { + return; + } + const canonical = this._canonicalAllowedFolder(selected); + if (!this.uriIdentityService.extUri.isEqual(canonical, selected)) { + this._selectFolder(canonical, true, undefined, false, this._preselectionSource, this._selectionOrigin); + } else if (this._resolveFolder(selected)) { + this._resolveSelectedAllowedFolder(); + this._updateTriggerLabel(); + this._onDidChangeSelection.fire(); } } From 3690943aab4f1d7283c2ada6c6ee13ce4fa5b9cd Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 13:34:50 +0100 Subject: [PATCH 120/165] Worktree support: the folder chip reads again whether a folder it does not list is a worktree as it reads the worktrees again, such as after a failed read Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostWorktreeFolderChoices.ts | 29 +++++++++++++----- .../agentHostWorktreeFolderChoices.test.ts | 30 +++++++++++++++++-- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts index 755f346404b4f5..4b1f388b88b6de 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts @@ -48,8 +48,13 @@ export class AgentHostWorktreeFolderChoices extends Disposable { /** The folder's worktrees, read ahead of time, as the chip asks for its choices synchronously. */ private _locations: readonly IFolderLocation[] = []; - /** Whether folders it does not list are worktrees of the window's folder, by the window's folder and the folder. */ - private readonly _provenWorktrees = new Map(); + /** + * Whether folders it does not list are worktrees of the window's folder, by the window's folder and the folder, or still + * being resolved: read again as it reads the worktrees again (see {@link refresh}), such as after a failed read. + */ + private readonly _provenWorktrees = new Map(); + /** Increases as it reads the worktrees again: what resolved before then does not apply. */ + private _provenGeneration = 0; private readonly _refresh = this._register(new MutableDisposable()); private readonly _onDidChange = this._register(new Emitter()); @@ -90,6 +95,8 @@ export class AgentHostWorktreeFolderChoices extends Disposable { /** Reads the folder's worktrees again, such as one that a session created since. */ refresh(): void { + this._provenWorktrees.clear(); + this._provenGeneration++; const primary = this._workspaceContextService.getWorkspace().uri; if (!primary || !this.isActive()) { this._refresh.clear(); @@ -213,13 +220,21 @@ export class AgentHostWorktreeFolderChoices extends Disposable { const key = `${primary.toString()}\n${folder.toString()}`; const known = this._provenWorktrees.get(key); if (known === undefined) { - this._provenWorktrees.set(key, false); - this._folderLocationService.resolveLocation(primary, folder, CancellationToken.None).then(location => { - if (location && !this._store.isDisposed) { - this._provenWorktrees.set(key, true); + this._provenWorktrees.set(key, 'resolving'); + const generation = this._provenGeneration; + const settle = (worktree: boolean) => { + if (generation !== this._provenGeneration || this._store.isDisposed) { + return; + } + this._provenWorktrees.set(key, worktree); + if (worktree) { this._onDidChange.fire(); } - }, onUnexpectedError); + }; + this._folderLocationService.resolveLocation(primary, folder, CancellationToken.None).then(location => settle(!!location), error => { + settle(false); + onUnexpectedError(error); + }); } return known === true; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts index 7e7e4801759b02..e63ac6f599fed6 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts @@ -32,7 +32,7 @@ suite('AgentHostWorktreeFolderChoices', () => { const session = URI.from({ scheme: SessionType.AgentHostCopilot, path: '/untitled-1' }); /** The choices in a window whose folder is at `at`, recording the folders and isolation it sets for the session. */ - async function createChoices(options: { readonly enabled: boolean; readonly at: URI; readonly isolation?: string; readonly state?: WorkbenchState; readonly listed?: readonly URI[] }) { + async function createChoices(options: { readonly enabled: boolean; readonly at: URI; readonly isolation?: string; readonly state?: WorkbenchState; readonly listed?: readonly URI[]; readonly resolveLocation?: (location: URI) => Promise }) { const config = { values: { isolation: options.isolation ?? 'folder' } as Record }; const changes: string[] = []; const listed = { locations: options.listed ?? [feature, other] }; @@ -52,7 +52,7 @@ suite('AgentHostWorktreeFolderChoices', () => { getLocations: async () => listed.locations.map(uri => upcastPartial({ uri, label: uri.path.split('/').pop() })), getLocation: () => options.at.path === primary.path ? undefined : upcastPartial({ uri: options.at, label: options.at.path.split('/').pop() }), // As git links them: only the worktrees of the window's folder resolve, not another repository or the folder itself - resolveLocation: async (_primary: URI, location: URI) => location.path.startsWith('/work/vscode.worktrees/') ? upcastPartial({ uri: location }) : undefined, + resolveLocation: async (_primary: URI, location: URI) => options.resolveLocation ? options.resolveLocation(location) : location.path.startsWith('/work/vscode.worktrees/') ? upcastPartial({ uri: location }) : undefined, })); instantiationService.stub(IAgentHostNewSessionFolderService, upcastPartial({ getFolder: () => undefined, @@ -120,6 +120,32 @@ suite('AgentHostWorktreeFolderChoices', () => { }); }); + test('a folder it does not list, which a failed read does not prove a worktree, is read again as it reads the worktrees again, and shows as a worktree once proven', async () => { + const unlisted = URI.file('/work/vscode.worktrees/small-random-change-addition'); + let reads = 0; + let readable = false; + const { choices } = await createChoices({ + enabled: true, at: primary, resolveLocation: async location => { + reads++; + // As the providers answer where git could not be read: not a location + return readable ? upcastPartial({ uri: location }) : undefined; + }, + }); + const settle = () => new Promise(resolve => setTimeout(resolve, 0)); + const icon = () => choices.getChip(unlisted).icon.id; + const failed = icon(); + await settle(); + // Read once, as it renders again + const afterFailure = [icon(), icon()]; + readable = true; + // Such as as a provider or the folder's location changes + choices.refresh(); + const pending = icon(); + await settle(); + + assert.deepStrictEqual({ failed, afterFailure, pending, proven: icon(), reads }, { failed: 'folder', afterFailure: ['folder', 'folder'], pending: 'folder', proven: 'worktree', reads: 2 }); + }); + test('is active only with the setting on in a window with a single local folder', async () => { assert.deepStrictEqual({ on: (await createChoices({ enabled: true, at: primary })).choices.isActive(), From 924095308b60a8e95a398fcdd934ca03e72fcafe Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 15:26:38 +0100 Subject: [PATCH 121/165] Worktree support: the Chat view's sessions list also lists the sessions of the window's repository and its worktrees, as the Sessions list does Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostSessionListContribution.ts | 5 +- .../agentHost/agentHostSessionListStore.ts | 36 ++++- .../agentHostWorktreeSessionListScope.ts | 136 ++++++++++++++++++ .../agentHostWorktreeSessionListScope.test.ts | 124 ++++++++++++++++ .../agentHostChatContribution.test.ts | 5 + 5 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeSessionListScope.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeSessionListScope.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts index ad7f065d989994..c9fe2dafe6234f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts @@ -21,6 +21,7 @@ import { IAgentHostSessionWorkingDirectoryResolver } from './agentHostSessionWor import { AgentHostSessionListController } from './agentHostSessionListController.js'; import { MovedChatEditorRetargeter } from './agentHostChatMoveEditors.js'; import { AgentHostSessionListStore } from './agentHostSessionListStore.js'; +import { AgentHostWorktreeSessionListScope } from './agentHostWorktreeSessionListScope.js'; export class AgentHostSessionListContribution extends Disposable implements IWorkbenchContribution { @@ -74,7 +75,9 @@ export class AgentHostSessionListContribution extends Disposable implements IWor return; } this._initialized = true; - const sessionListStore = this._register(this._instantiationService.createInstance(AgentHostSessionListStore, this._agentHostService, { retainUnlistedProvider: provider => isLocalAgentHostProviderDisabledByPolicy(provider, this._configurationService) })); + // With worktree support, also the sessions of the repositories of the window's folders and their worktrees + const scope = this._register(this._instantiationService.createInstance(AgentHostWorktreeSessionListScope)); + const sessionListStore = this._register(this._instantiationService.createInstance(AgentHostSessionListStore, this._agentHostService, { retainUnlistedProvider: provider => isLocalAgentHostProviderDisabledByPolicy(provider, this._configurationService), scope })); this._sessionListStore = sessionListStore; this._register(this._agentHostService.rootState.onDidChange(rootState => { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts index 13dfdc7865b417..0b4fa4f583eb12 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts @@ -36,6 +36,17 @@ export interface IAgentHostSessionListConnection { dispatch(channel: string, action: SessionAction | ChatIsArchivedChangedAction | ChatIsReadChangedAction): void; } +/** + * More sessions that a window lists besides those working in its folders, such as the sessions of the git repositories + * of its folders and their worktrees. Only reads what it knows already: no I/O per session. + */ +export interface IAgentHostSessionListScope { + /** Whether a session working in `workingDirectories`, of the repository `project`, is listed too. */ + includes(workingDirectories: readonly URI[], project: URI | undefined): boolean; + /** Fires as what it includes changes, such as as the worktrees of the window's folders become known. */ + readonly onDidChange: Event; +} + /** * Provider-tagged backend session entry owned by the shared session-list store. */ @@ -115,6 +126,8 @@ export class AgentHostSessionListStore extends Disposable { readonly retainUnlistedProvider?: (provider: string) => boolean; /** Retain unlisted sessions whose existence is owned by another inventory. */ readonly retainSession?: (session: URI) => boolean; + /** Also lists the sessions it includes, in a window with folders. */ + readonly scope?: IAgentHostSessionListScope; } = {}, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @ILogService private readonly _logService: ILogService, @@ -143,6 +156,18 @@ export class AgentHostSessionListStore extends Disposable { this._filterEntriesToWorkspace(); void this.refresh(CancellationToken.None); })); + + // What the scope includes can narrow, such as a worktree that is gone, or widen, which only the host's list supplies + if (this._options.scope) { + this._register(this._options.scope.onDidChange(() => { + if (this._options.filterToWorkspace === false) { + return; + } + this._cacheValid = false; + this._filterEntriesToWorkspace(); + void this.refresh(CancellationToken.None); + })); + } } getSessions(provider: string): readonly IAgentHostSessionListEntry[] { @@ -550,7 +575,7 @@ export class AgentHostSessionListStore extends Disposable { if (folders.length === 0) { return true; } - return this._matchesAnyFolder(workingDirectories, folders); + return this._matchesAnyFolder(workingDirectories, folders) || this._isInScope(entry.summary); } private _filterEntriesToWorkspace(): void { @@ -569,6 +594,15 @@ export class AgentHostSessionListStore extends Disposable { } } + private _isInScope(summary: SessionSummary): boolean { + const scope = this._options.scope; + if (!scope) { + return false; + } + const project = summary.project?.uri ? URI.parse(summary.project.uri) : undefined; + return scope.includes(summary.workingDirectories?.map(directory => URI.parse(directory)) ?? [], project); + } + private _matchesAnyFolder(workingDirectories: readonly URI[], folders: readonly IWorkspaceFolder[]): boolean { return workingDirectories.some(directory => folders.some(folder => extUriBiasedIgnorePathCase.isEqualOrParent(directory, folder.uri)) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeSessionListScope.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeSessionListScope.ts new file mode 100644 index 00000000000000..01dc89d00b3b97 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeSessionListScope.ts @@ -0,0 +1,136 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RunOnceScheduler } from '../../../../../../base/common/async.js'; +import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; +import { onUnexpectedError } from '../../../../../../base/common/errors.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Disposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../../base/common/network.js'; +import { extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { getRepositoryRootFromWorktree } from '../../../../../../platform/agentHost/common/worktreePaths.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IWorkspaceFolderLocationService } from '../../../../../../platform/workspace/common/folderLocations.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { ChatConfiguration } from '../../../common/constants.js'; +import { IAgentHostSessionListScope } from './agentHostSessionListStore.js'; +import { IAgentHostWorktreeSessionSources } from './agentHostWorktreeSessionSources.js'; + +/** + * While worktree support (`chat.editorWindow.enableWorktree`) is on, the Chat view's sessions list also lists the sessions + * of the git repositories of the window's folders, as the Sessions list does: those of the repository a session reports, + * and those working in a worktree of one, by the `.worktrees/` path that VS Code gives its worktrees, or + * as a location of the window's folder that git proves one, such as a worktree elsewhere. Also sessions another window's + * agent host owns, which report only their worktree. Off, it lists no more than the sessions working in the folders. + */ +export class AgentHostWorktreeSessionListScope extends Disposable implements IAgentHostSessionListScope { + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + private _enabled: boolean; + /** The locations of the window's folder, such as its worktrees: kept until a read of them completes, so nothing drops out meanwhile. */ + private _locations: readonly URI[] = []; + private readonly _read = this._register(new MutableDisposable()); + private readonly _readSoon = this._register(new RunOnceScheduler(() => this._readLocations(), 500)); + + constructor( + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IWorkspaceFolderLocationService private readonly _folderLocationService: IWorkspaceFolderLocationService, + @IAgentHostWorktreeSessionSources sessionSources: IAgentHostWorktreeSessionSources, + ) { + super(); + this._enabled = this._isEnabled(); + this._register(_configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ChatConfiguration.EditorWindowEnableWorktree) && this._isEnabled() !== this._enabled) { + this._enabled = !this._enabled; + this._locations = []; + this._onDidChange.fire(); + this._readLocations(); + } + })); + this._register(_folderLocationService.onDidChangeProviders(() => this._readLocations())); + this._register(_folderLocationService.onDidChangeLocation(() => this._readLocations())); + this._register(_workspaceContextService.onDidChangeWorkspaceFolders(() => this._readLocations())); + // A session can come to work in a new worktree meanwhile: read once as the sessions settle + this._register(sessionSources.onDidChange(() => { + if (this._enabled) { + this._readSoon.schedule(); + } + })); + this._readLocations(); + } + + includes(workingDirectories: readonly URI[], project: URI | undefined): boolean { + if (!this._enabled) { + return false; + } + const repositories = this._repositories(); + if (!repositories.length) { + return false; + } + const isRepository = (uri: URI | undefined) => !!uri && repositories.some(repository => extUriBiasedIgnorePathCase.isEqual(repository, uri)); + const repositoryOf = (uri: URI) => uri.scheme === Schemas.file ? getRepositoryRootFromWorktree(uri) : undefined; + // Its repository, or, as a session that another window's agent host owns reports, its worktree as its project + if (project && (isRepository(project) || isRepository(repositoryOf(project)))) { + return true; + } + return workingDirectories.some(directory => isRepository(repositoryOf(directory)) + || this._locations.some(location => extUriBiasedIgnorePathCase.isEqualOrParent(directory, location))); + } + + private _isEnabled(): boolean { + return this._configurationService.getValue(ChatConfiguration.EditorWindowEnableWorktree) === true; + } + + /** The repositories of the window's folders: each folder, the folder's own location, and the repository of a worktree. */ + private _repositories(): URI[] { + const workspace = this._workspaceContextService.getWorkspace(); + const repositories: URI[] = []; + const add = (uri: URI | undefined) => { + if (uri && !repositories.some(known => extUriBiasedIgnorePathCase.isEqual(known, uri))) { + repositories.push(uri); + } + }; + for (const folder of workspace.folders) { + add(folder.uri); + add(folder.uri.scheme === Schemas.file ? getRepositoryRootFromWorktree(folder.uri) : undefined); + } + if (!workspace.configuration) { + add(workspace.uri); + } + return repositories; + } + + /** Reads the locations of the window's folder again, once per change, not per session. */ + private _readLocations(): void { + this._readSoon.cancel(); + const workspace = this._workspaceContextService.getWorkspace(); + const folder = workspace.folders[0]; + const primary = workspace.uri; + if (!this._enabled || !folder || !primary || !this._folderLocationService.canSetLocation(folder)) { + this._read.clear(); + this._setLocations([]); + return; + } + const source = new CancellationTokenSource(); + this._read.value = toDisposable(() => source.dispose(true)); + this._folderLocationService.getLocations(primary, source.token).then(locations => { + if (!source.token.isCancellationRequested) { + this._setLocations(locations.map(location => location.uri)); + } + }, onUnexpectedError); + } + + private _setLocations(locations: readonly URI[]): void { + const same = locations.length === this._locations.length && locations.every(location => this._locations.some(known => extUriBiasedIgnorePathCase.isEqual(known, location))); + if (!same) { + this._locations = locations; + this._onDidChange.fire(); + } + } +} diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeSessionListScope.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeSessionListScope.test.ts new file mode 100644 index 00000000000000..476f44f2ea3891 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeSessionListScope.test.ts @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CancellationToken } from '../../../../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../../../../base/common/event.js'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { IAgentHostConnectionsService } from '../../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { identityAgentHostResourceUriMapper } from '../../../../../../../platform/agentHost/common/agentHostUri.js'; +import { IAgentSessionMetadata } from '../../../../../../../platform/agentHost/common/agentService.js'; +import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../../../platform/log/common/log.js'; +import { IFolderLocation, IWorkspaceFolderLocationService } from '../../../../../../../platform/workspace/common/folderLocations.js'; +import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../../platform/workspace/common/workspace.js'; +import { AgentHostSessionListStore, IAgentHostSessionListConnection } from '../../../../browser/agentSessions/agentHost/agentHostSessionListStore.js'; +import { AgentHostWorktreeSessionListScope } from '../../../../browser/agentSessions/agentHost/agentHostWorktreeSessionListScope.js'; +import { AgentHostWorktreeSessionSources, IAgentHostWorktreeSessionSources } from '../../../../browser/agentSessions/agentHost/agentHostWorktreeSessionSources.js'; +import { ChatConfiguration } from '../../../../common/constants.js'; + +suite('AgentHostWorktreeSessionListScope', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + const session = (id: string, workingDirectory: string, project?: string): IAgentSessionMetadata => ({ + session: URI.from({ scheme: 'copilot', path: `/${id}` }), + startTime: 0, + modifiedTime: 0, + workingDirectories: [URI.file(workingDirectory)], + ...(project ? { project: { uri: URI.file(project), displayName: project.split('/').pop()! } } : {}), + }); + + /** The Chat view's sessions list of a window on `/work/vscode`, with the sessions its agent host lists. */ + function createList(options: { readonly enabled: boolean; readonly sessions: readonly IAgentSessionMetadata[]; readonly locations?: readonly string[] }) { + const configurationService = new TestConfigurationService({ [ChatConfiguration.EditorWindowEnableWorktree]: options.enabled }); + let locations = options.locations ?? []; + const onDidChangeProviders = store.add(new Emitter()); + let listed = 0; + let locationReads = 0; + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IAgentHostConnectionsService, upcastPartial({ registerSessionResource: (backendSession: URI) => backendSession })); + instantiationService.stub(IWorkspaceContextService, upcastPartial({ + getWorkspace: () => upcastPartial({ uri: URI.file('/work/vscode'), folders: [upcastPartial({ uri: URI.file('/work/vscode') })] }), + onDidChangeWorkspaceFolders: Event.None, + })); + instantiationService.stub(IWorkspaceFolderLocationService, upcastPartial({ + onDidChangeProviders: onDidChangeProviders.event, + onDidChangeLocation: Event.None, + canSetLocation: () => true, + getLocations: async () => { locationReads++; return locations.map(path => upcastPartial({ uri: URI.file(path) })); }, + })); + instantiationService.stub(IAgentHostWorktreeSessionSources, store.add(new AgentHostWorktreeSessionSources())); + const connection: IAgentHostSessionListConnection = { + onDidNotification: Event.None, + resourceUris: identityAgentHostResourceUriMapper, + listSessions: async () => { listed++; return [...options.sessions]; }, + disposeChat: async () => { }, + disposeSession: async () => { }, + dispatch: () => { }, + }; + const scope = store.add(instantiationService.createInstance(AgentHostWorktreeSessionListScope)); + const list = store.add(instantiationService.createInstance(AgentHostSessionListStore, connection, { scope })); + const shown = () => list.getSessions('copilot').map(entry => entry.rawId).sort(); + const settle = async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + await list.refresh(CancellationToken.None); + }; + return { + list, shown, settle, + calls: () => ({ listed, locationReads }), + setLocations: (next: readonly string[]) => { locations = next; onDidChangeProviders.fire(); }, + }; + } + + const sessions = [ + session('in-folder', '/work/vscode/src'), + // Native: works in a worktree of the window's repository, which it reports as its project + session('native', '/work/vscode.worktrees/feature', '/work/vscode'), + // Another window's agent host owns it: it reports only its worktree, also as its project + session('external', '/work/vscode.worktrees/from-insiders', '/work/vscode.worktrees/from-insiders'), + // A worktree elsewhere, which only git knows as a location of the window's folder + session('elsewhere', '/tmp/checkouts/fix'), + session('unrelated', '/work/other.worktrees/x', '/work/other'), + ]; + + test('with worktree support, lists the sessions of the window\'s repository and its worktrees, also of another agent host, but not another repository\'s', async () => { + const { list, shown, settle } = createList({ enabled: true, sessions, locations: ['/tmp/checkouts/fix'] }); + await settle(); + await list.refresh(CancellationToken.None); + + assert.deepStrictEqual(shown(), ['elsewhere', 'external', 'in-folder', 'native']); + }); + + test('without worktree support, lists only the sessions working in the window\'s folders, as before', async () => { + const { shown, settle } = createList({ enabled: false, sessions, locations: ['/tmp/checkouts/fix'] }); + await settle(); + + assert.deepStrictEqual(shown(), ['in-folder']); + }); + + test('a worktree that is no location of the window\'s folder any more drops its sessions, read once per change rather than per session or refresh', async () => { + const { list, shown, settle, setLocations, calls } = createList({ enabled: true, sessions, locations: ['/tmp/checkouts/fix'] }); + await settle(); + const before = { shown: shown(), calls: calls() }; + // Refreshing again reads neither the host's list nor the locations again + await list.refresh(CancellationToken.None); + const afterRefresh = calls(); + setLocations([]); + await settle(); + + assert.deepStrictEqual({ before, afterRefresh, after: shown() }, { + before: { shown: ['elsewhere', 'external', 'in-folder', 'native'], calls: { listed: 1, locationReads: 1 } }, + afterRefresh: { listed: 1, locationReads: 1 }, + after: ['external', 'in-folder', 'native'], + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index c0e8a4b18ed7a7..428f17db5e8578 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -144,6 +144,8 @@ import type { IAgentHostFirstResponseDiagnostic } from '../../../../../../platfo import { AgentHostAuthTokenCache } from '../../../browser/agentSessions/agentHost/agentHostAuth.js'; import { AgentHostLanguageModelProvider } from '../../../browser/agentSessions/agentHost/agentHostLanguageModelProvider.js'; import { AgentHostSessionListContribution } from '../../../browser/agentSessions/agentHost/agentHostSessionListContribution.js'; +import { AgentHostWorktreeSessionSources, IAgentHostWorktreeSessionSources } from '../../../browser/agentSessions/agentHost/agentHostWorktreeSessionSources.js'; +import { IWorkspaceFolderLocationService } from '../../../../../../platform/workspace/common/folderLocations.js'; import { IComputerUseSetupService } from '../../../browser/agentSessions/agentHost/computerUseSetup.js'; import { AgentHostSessionListController } from '../../../browser/agentSessions/agentHost/agentHostSessionListController.js'; import { AgentHostSessionListStore, type IAgentHostSessionListConnection } from '../../../browser/agentSessions/agentHost/agentHostSessionListStore.js'; @@ -965,6 +967,9 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv }; instantiationService.stub(IAccessibilitySignalService, accessibilitySignalService as IAccessibilitySignalService); instantiationService.stub(IAccessibilityService, new TestAccessibilityService()); + // The window's folder has no other locations, and no session sources: worktree support adds no sessions to the list + instantiationService.stub(IWorkspaceFolderLocationService, upcastPartial({ onDidChangeProviders: Event.None, onDidChangeLocation: Event.None, canSetLocation: () => false })); + instantiationService.stub(IAgentHostWorktreeSessionSources, disposables.add(new AgentHostWorktreeSessionSources())); const agentHostService = new MockAgentHostService(); disposables.add(toDisposable(() => agentHostService.dispose())); From 0fc969e5df7232cf191511d0ecbda3c63f7511fc Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 15:31:30 +0100 Subject: [PATCH 122/165] Worktree support: the Chat view's sessions list lists the sessions whose project is the window's folder, instead of matching their repository and worktrees Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostSessionListContribution.ts | 9 +- .../agentHost/agentHostSessionListStore.ts | 37 ++--- .../agentHostWorktreeSessionListScope.ts | 136 ------------------ ...entHostSessionListStoreProjectRoot.test.ts | 77 ++++++++++ .../agentHostWorktreeSessionListScope.test.ts | 124 ---------------- .../agentHostChatContribution.test.ts | 5 - 6 files changed, 96 insertions(+), 292 deletions(-) delete mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeSessionListScope.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostSessionListStoreProjectRoot.test.ts delete mode 100644 src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeSessionListScope.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts index c9fe2dafe6234f..5bb4f47a09c0a6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts @@ -21,7 +21,8 @@ import { IAgentHostSessionWorkingDirectoryResolver } from './agentHostSessionWor import { AgentHostSessionListController } from './agentHostSessionListController.js'; import { MovedChatEditorRetargeter } from './agentHostChatMoveEditors.js'; import { AgentHostSessionListStore } from './agentHostSessionListStore.js'; -import { AgentHostWorktreeSessionListScope } from './agentHostWorktreeSessionListScope.js'; +import { ChatConfiguration } from '../../../common/constants.js'; +import { observableConfigValue } from '../../../../../../platform/observable/common/platformObservableUtils.js'; export class AgentHostSessionListContribution extends Disposable implements IWorkbenchContribution { @@ -75,9 +76,9 @@ export class AgentHostSessionListContribution extends Disposable implements IWor return; } this._initialized = true; - // With worktree support, also the sessions of the repositories of the window's folders and their worktrees - const scope = this._register(this._instantiationService.createInstance(AgentHostWorktreeSessionListScope)); - const sessionListStore = this._register(this._instantiationService.createInstance(AgentHostSessionListStore, this._agentHostService, { retainUnlistedProvider: provider => isLocalAgentHostProviderDisabledByPolicy(provider, this._configurationService), scope })); + // With worktree support, also the sessions whose project is a folder of the window, such as those in its worktrees + const matchProjectRoot = observableConfigValue(ChatConfiguration.EditorWindowEnableWorktree, false, this._configurationService); + const sessionListStore = this._register(this._instantiationService.createInstance(AgentHostSessionListStore, this._agentHostService, { retainUnlistedProvider: provider => isLocalAgentHostProviderDisabledByPolicy(provider, this._configurationService), matchProjectRoot })); this._sessionListStore = sessionListStore; this._register(this._agentHostService.rootState.onDidChange(rootState => { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts index 0b4fa4f583eb12..30ab965b06169d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts @@ -16,7 +16,7 @@ import { readSessionMatchesByProjectRoot, readSessionMultiRootMetadata, SessionS import { IWorkspaceContextService, type IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { Schemas } from '../../../../../../base/common/network.js'; -import { IObservable } from '../../../../../../base/common/observable.js'; +import { IObservable, runOnChange } from '../../../../../../base/common/observable.js'; import { InitializeResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { ChatIsArchivedChangedAction, ChatIsReadChangedAction } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; import { isActionKnownToVersion } from '../../../../../../platform/agentHost/common/state/protocol/version/registry.js'; @@ -36,17 +36,6 @@ export interface IAgentHostSessionListConnection { dispatch(channel: string, action: SessionAction | ChatIsArchivedChangedAction | ChatIsReadChangedAction): void; } -/** - * More sessions that a window lists besides those working in its folders, such as the sessions of the git repositories - * of its folders and their worktrees. Only reads what it knows already: no I/O per session. - */ -export interface IAgentHostSessionListScope { - /** Whether a session working in `workingDirectories`, of the repository `project`, is listed too. */ - includes(workingDirectories: readonly URI[], project: URI | undefined): boolean; - /** Fires as what it includes changes, such as as the worktrees of the window's folders become known. */ - readonly onDidChange: Event; -} - /** * Provider-tagged backend session entry owned by the shared session-list store. */ @@ -126,8 +115,11 @@ export class AgentHostSessionListStore extends Disposable { readonly retainUnlistedProvider?: (provider: string) => boolean; /** Retain unlisted sessions whose existence is owned by another inventory. */ readonly retainSession?: (session: URI) => boolean; - /** Also lists the sessions it includes, in a window with folders. */ - readonly scope?: IAgentHostSessionListScope; + /** + * Also lists a session whose project (repository root) is a folder of the window, such as one working in a + * worktree of it, while true. + */ + readonly matchProjectRoot?: IObservable; } = {}, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @ILogService private readonly _logService: ILogService, @@ -157,9 +149,9 @@ export class AgentHostSessionListStore extends Disposable { void this.refresh(CancellationToken.None); })); - // What the scope includes can narrow, such as a worktree that is gone, or widen, which only the host's list supplies - if (this._options.scope) { - this._register(this._options.scope.onDidChange(() => { + // Matching by project narrows the list as it turns off, and widens it as it turns on, which only the host's list supplies + if (this._options.matchProjectRoot) { + this._register(runOnChange(this._options.matchProjectRoot, () => { if (this._options.filterToWorkspace === false) { return; } @@ -575,7 +567,7 @@ export class AgentHostSessionListStore extends Disposable { if (folders.length === 0) { return true; } - return this._matchesAnyFolder(workingDirectories, folders) || this._isInScope(entry.summary); + return this._matchesAnyFolder(workingDirectories, folders) || this._matchesProjectRoot(entry.summary, folders); } private _filterEntriesToWorkspace(): void { @@ -594,13 +586,12 @@ export class AgentHostSessionListStore extends Disposable { } } - private _isInScope(summary: SessionSummary): boolean { - const scope = this._options.scope; - if (!scope) { + private _matchesProjectRoot(summary: SessionSummary, folders: readonly IWorkspaceFolder[]): boolean { + if (!summary.project?.uri || !this._options.matchProjectRoot?.get()) { return false; } - const project = summary.project?.uri ? URI.parse(summary.project.uri) : undefined; - return scope.includes(summary.workingDirectories?.map(directory => URI.parse(directory)) ?? [], project); + const project = URI.parse(summary.project.uri); + return folders.some(folder => extUriBiasedIgnorePathCase.isEqual(project, folder.uri)); } private _matchesAnyFolder(workingDirectories: readonly URI[], folders: readonly IWorkspaceFolder[]): boolean { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeSessionListScope.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeSessionListScope.ts deleted file mode 100644 index 01dc89d00b3b97..00000000000000 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeSessionListScope.ts +++ /dev/null @@ -1,136 +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 { RunOnceScheduler } from '../../../../../../base/common/async.js'; -import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; -import { onUnexpectedError } from '../../../../../../base/common/errors.js'; -import { Emitter, Event } from '../../../../../../base/common/event.js'; -import { Disposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; -import { Schemas } from '../../../../../../base/common/network.js'; -import { extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; -import { URI } from '../../../../../../base/common/uri.js'; -import { getRepositoryRootFromWorktree } from '../../../../../../platform/agentHost/common/worktreePaths.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { IWorkspaceFolderLocationService } from '../../../../../../platform/workspace/common/folderLocations.js'; -import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; -import { ChatConfiguration } from '../../../common/constants.js'; -import { IAgentHostSessionListScope } from './agentHostSessionListStore.js'; -import { IAgentHostWorktreeSessionSources } from './agentHostWorktreeSessionSources.js'; - -/** - * While worktree support (`chat.editorWindow.enableWorktree`) is on, the Chat view's sessions list also lists the sessions - * of the git repositories of the window's folders, as the Sessions list does: those of the repository a session reports, - * and those working in a worktree of one, by the `.worktrees/` path that VS Code gives its worktrees, or - * as a location of the window's folder that git proves one, such as a worktree elsewhere. Also sessions another window's - * agent host owns, which report only their worktree. Off, it lists no more than the sessions working in the folders. - */ -export class AgentHostWorktreeSessionListScope extends Disposable implements IAgentHostSessionListScope { - - private readonly _onDidChange = this._register(new Emitter()); - readonly onDidChange: Event = this._onDidChange.event; - - private _enabled: boolean; - /** The locations of the window's folder, such as its worktrees: kept until a read of them completes, so nothing drops out meanwhile. */ - private _locations: readonly URI[] = []; - private readonly _read = this._register(new MutableDisposable()); - private readonly _readSoon = this._register(new RunOnceScheduler(() => this._readLocations(), 500)); - - constructor( - @IConfigurationService private readonly _configurationService: IConfigurationService, - @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, - @IWorkspaceFolderLocationService private readonly _folderLocationService: IWorkspaceFolderLocationService, - @IAgentHostWorktreeSessionSources sessionSources: IAgentHostWorktreeSessionSources, - ) { - super(); - this._enabled = this._isEnabled(); - this._register(_configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ChatConfiguration.EditorWindowEnableWorktree) && this._isEnabled() !== this._enabled) { - this._enabled = !this._enabled; - this._locations = []; - this._onDidChange.fire(); - this._readLocations(); - } - })); - this._register(_folderLocationService.onDidChangeProviders(() => this._readLocations())); - this._register(_folderLocationService.onDidChangeLocation(() => this._readLocations())); - this._register(_workspaceContextService.onDidChangeWorkspaceFolders(() => this._readLocations())); - // A session can come to work in a new worktree meanwhile: read once as the sessions settle - this._register(sessionSources.onDidChange(() => { - if (this._enabled) { - this._readSoon.schedule(); - } - })); - this._readLocations(); - } - - includes(workingDirectories: readonly URI[], project: URI | undefined): boolean { - if (!this._enabled) { - return false; - } - const repositories = this._repositories(); - if (!repositories.length) { - return false; - } - const isRepository = (uri: URI | undefined) => !!uri && repositories.some(repository => extUriBiasedIgnorePathCase.isEqual(repository, uri)); - const repositoryOf = (uri: URI) => uri.scheme === Schemas.file ? getRepositoryRootFromWorktree(uri) : undefined; - // Its repository, or, as a session that another window's agent host owns reports, its worktree as its project - if (project && (isRepository(project) || isRepository(repositoryOf(project)))) { - return true; - } - return workingDirectories.some(directory => isRepository(repositoryOf(directory)) - || this._locations.some(location => extUriBiasedIgnorePathCase.isEqualOrParent(directory, location))); - } - - private _isEnabled(): boolean { - return this._configurationService.getValue(ChatConfiguration.EditorWindowEnableWorktree) === true; - } - - /** The repositories of the window's folders: each folder, the folder's own location, and the repository of a worktree. */ - private _repositories(): URI[] { - const workspace = this._workspaceContextService.getWorkspace(); - const repositories: URI[] = []; - const add = (uri: URI | undefined) => { - if (uri && !repositories.some(known => extUriBiasedIgnorePathCase.isEqual(known, uri))) { - repositories.push(uri); - } - }; - for (const folder of workspace.folders) { - add(folder.uri); - add(folder.uri.scheme === Schemas.file ? getRepositoryRootFromWorktree(folder.uri) : undefined); - } - if (!workspace.configuration) { - add(workspace.uri); - } - return repositories; - } - - /** Reads the locations of the window's folder again, once per change, not per session. */ - private _readLocations(): void { - this._readSoon.cancel(); - const workspace = this._workspaceContextService.getWorkspace(); - const folder = workspace.folders[0]; - const primary = workspace.uri; - if (!this._enabled || !folder || !primary || !this._folderLocationService.canSetLocation(folder)) { - this._read.clear(); - this._setLocations([]); - return; - } - const source = new CancellationTokenSource(); - this._read.value = toDisposable(() => source.dispose(true)); - this._folderLocationService.getLocations(primary, source.token).then(locations => { - if (!source.token.isCancellationRequested) { - this._setLocations(locations.map(location => location.uri)); - } - }, onUnexpectedError); - } - - private _setLocations(locations: readonly URI[]): void { - const same = locations.length === this._locations.length && locations.every(location => this._locations.some(known => extUriBiasedIgnorePathCase.isEqual(known, location))); - if (!same) { - this._locations = locations; - this._onDidChange.fire(); - } - } -} diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostSessionListStoreProjectRoot.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostSessionListStoreProjectRoot.test.ts new file mode 100644 index 00000000000000..f1249605d53fca --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostSessionListStoreProjectRoot.test.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CancellationToken } from '../../../../../../../base/common/cancellation.js'; +import { Event } from '../../../../../../../base/common/event.js'; +import { observableValue } from '../../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { IAgentHostConnectionsService } from '../../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { identityAgentHostResourceUriMapper } from '../../../../../../../platform/agentHost/common/agentHostUri.js'; +import { IAgentSessionMetadata } from '../../../../../../../platform/agentHost/common/agentService.js'; +import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../../../platform/log/common/log.js'; +import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../../platform/workspace/common/workspace.js'; +import { AgentHostSessionListStore, IAgentHostSessionListConnection } from '../../../../browser/agentSessions/agentHost/agentHostSessionListStore.js'; + +suite('AgentHostSessionListStore - project root', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + const session = (id: string, workingDirectory: string, project: string): IAgentSessionMetadata => ({ + session: URI.from({ scheme: 'copilot', path: `/${id}` }), + startTime: 0, + modifiedTime: 0, + workingDirectories: [URI.file(workingDirectory)], + project: { uri: URI.file(project), displayName: project.split('/').pop()! }, + }); + + test('while matching by project root, also lists the sessions whose project is a folder of the window, such as in its worktrees, and lists them again as it turns on and off', async () => { + const matchProjectRoot = observableValue('matchProjectRoot', false); + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IAgentHostConnectionsService, upcastPartial({ registerSessionResource: (backendSession: URI) => backendSession })); + instantiationService.stub(IWorkspaceContextService, upcastPartial({ + getWorkspace: () => upcastPartial({ uri: URI.file('/work/vscode'), folders: [upcastPartial({ uri: URI.file('/work/vscode') })] }), + onDidChangeWorkspaceFolders: Event.None, + })); + const sessions = [ + session('in-folder', '/work/vscode/src', '/work/vscode'), + // Works in a worktree of the window's folder, which it reports as its project + session('worktree', '/work/vscode.worktrees/feature', '/work/vscode'), + session('unrelated', '/work/other.worktrees/x', '/work/other'), + ]; + const connection: IAgentHostSessionListConnection = { + onDidNotification: Event.None, + resourceUris: identityAgentHostResourceUriMapper, + listSessions: async () => [...sessions], + disposeChat: async () => { }, + disposeSession: async () => { }, + dispatch: () => { }, + }; + const list = store.add(instantiationService.createInstance(AgentHostSessionListStore, connection, { matchProjectRoot })); + const shown = () => list.getSessions('copilot').map(entry => entry.rawId).sort(); + const settle = async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + await list.refresh(CancellationToken.None); + }; + + await settle(); + const off = shown(); + matchProjectRoot.set(true, undefined); + await settle(); + const on = shown(); + matchProjectRoot.set(false, undefined); + await settle(); + + assert.deepStrictEqual({ off, on, offAgain: shown() }, { + off: ['in-folder'], + on: ['in-folder', 'worktree'], + offAgain: ['in-folder'], + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeSessionListScope.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeSessionListScope.test.ts deleted file mode 100644 index 476f44f2ea3891..00000000000000 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeSessionListScope.test.ts +++ /dev/null @@ -1,124 +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 { CancellationToken } from '../../../../../../../base/common/cancellation.js'; -import { Emitter, Event } from '../../../../../../../base/common/event.js'; -import { URI } from '../../../../../../../base/common/uri.js'; -import { upcastPartial } from '../../../../../../../base/test/common/mock.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; -import { IAgentHostConnectionsService } from '../../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; -import { identityAgentHostResourceUriMapper } from '../../../../../../../platform/agentHost/common/agentHostUri.js'; -import { IAgentSessionMetadata } from '../../../../../../../platform/agentHost/common/agentService.js'; -import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; -import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; -import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; -import { ILogService, NullLogService } from '../../../../../../../platform/log/common/log.js'; -import { IFolderLocation, IWorkspaceFolderLocationService } from '../../../../../../../platform/workspace/common/folderLocations.js'; -import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../../platform/workspace/common/workspace.js'; -import { AgentHostSessionListStore, IAgentHostSessionListConnection } from '../../../../browser/agentSessions/agentHost/agentHostSessionListStore.js'; -import { AgentHostWorktreeSessionListScope } from '../../../../browser/agentSessions/agentHost/agentHostWorktreeSessionListScope.js'; -import { AgentHostWorktreeSessionSources, IAgentHostWorktreeSessionSources } from '../../../../browser/agentSessions/agentHost/agentHostWorktreeSessionSources.js'; -import { ChatConfiguration } from '../../../../common/constants.js'; - -suite('AgentHostWorktreeSessionListScope', () => { - - const store = ensureNoDisposablesAreLeakedInTestSuite(); - - const session = (id: string, workingDirectory: string, project?: string): IAgentSessionMetadata => ({ - session: URI.from({ scheme: 'copilot', path: `/${id}` }), - startTime: 0, - modifiedTime: 0, - workingDirectories: [URI.file(workingDirectory)], - ...(project ? { project: { uri: URI.file(project), displayName: project.split('/').pop()! } } : {}), - }); - - /** The Chat view's sessions list of a window on `/work/vscode`, with the sessions its agent host lists. */ - function createList(options: { readonly enabled: boolean; readonly sessions: readonly IAgentSessionMetadata[]; readonly locations?: readonly string[] }) { - const configurationService = new TestConfigurationService({ [ChatConfiguration.EditorWindowEnableWorktree]: options.enabled }); - let locations = options.locations ?? []; - const onDidChangeProviders = store.add(new Emitter()); - let listed = 0; - let locationReads = 0; - const instantiationService = store.add(new TestInstantiationService()); - instantiationService.stub(IConfigurationService, configurationService); - instantiationService.stub(ILogService, new NullLogService()); - instantiationService.stub(IAgentHostConnectionsService, upcastPartial({ registerSessionResource: (backendSession: URI) => backendSession })); - instantiationService.stub(IWorkspaceContextService, upcastPartial({ - getWorkspace: () => upcastPartial({ uri: URI.file('/work/vscode'), folders: [upcastPartial({ uri: URI.file('/work/vscode') })] }), - onDidChangeWorkspaceFolders: Event.None, - })); - instantiationService.stub(IWorkspaceFolderLocationService, upcastPartial({ - onDidChangeProviders: onDidChangeProviders.event, - onDidChangeLocation: Event.None, - canSetLocation: () => true, - getLocations: async () => { locationReads++; return locations.map(path => upcastPartial({ uri: URI.file(path) })); }, - })); - instantiationService.stub(IAgentHostWorktreeSessionSources, store.add(new AgentHostWorktreeSessionSources())); - const connection: IAgentHostSessionListConnection = { - onDidNotification: Event.None, - resourceUris: identityAgentHostResourceUriMapper, - listSessions: async () => { listed++; return [...options.sessions]; }, - disposeChat: async () => { }, - disposeSession: async () => { }, - dispatch: () => { }, - }; - const scope = store.add(instantiationService.createInstance(AgentHostWorktreeSessionListScope)); - const list = store.add(instantiationService.createInstance(AgentHostSessionListStore, connection, { scope })); - const shown = () => list.getSessions('copilot').map(entry => entry.rawId).sort(); - const settle = async () => { - await new Promise(resolve => setTimeout(resolve, 0)); - await list.refresh(CancellationToken.None); - }; - return { - list, shown, settle, - calls: () => ({ listed, locationReads }), - setLocations: (next: readonly string[]) => { locations = next; onDidChangeProviders.fire(); }, - }; - } - - const sessions = [ - session('in-folder', '/work/vscode/src'), - // Native: works in a worktree of the window's repository, which it reports as its project - session('native', '/work/vscode.worktrees/feature', '/work/vscode'), - // Another window's agent host owns it: it reports only its worktree, also as its project - session('external', '/work/vscode.worktrees/from-insiders', '/work/vscode.worktrees/from-insiders'), - // A worktree elsewhere, which only git knows as a location of the window's folder - session('elsewhere', '/tmp/checkouts/fix'), - session('unrelated', '/work/other.worktrees/x', '/work/other'), - ]; - - test('with worktree support, lists the sessions of the window\'s repository and its worktrees, also of another agent host, but not another repository\'s', async () => { - const { list, shown, settle } = createList({ enabled: true, sessions, locations: ['/tmp/checkouts/fix'] }); - await settle(); - await list.refresh(CancellationToken.None); - - assert.deepStrictEqual(shown(), ['elsewhere', 'external', 'in-folder', 'native']); - }); - - test('without worktree support, lists only the sessions working in the window\'s folders, as before', async () => { - const { shown, settle } = createList({ enabled: false, sessions, locations: ['/tmp/checkouts/fix'] }); - await settle(); - - assert.deepStrictEqual(shown(), ['in-folder']); - }); - - test('a worktree that is no location of the window\'s folder any more drops its sessions, read once per change rather than per session or refresh', async () => { - const { list, shown, settle, setLocations, calls } = createList({ enabled: true, sessions, locations: ['/tmp/checkouts/fix'] }); - await settle(); - const before = { shown: shown(), calls: calls() }; - // Refreshing again reads neither the host's list nor the locations again - await list.refresh(CancellationToken.None); - const afterRefresh = calls(); - setLocations([]); - await settle(); - - assert.deepStrictEqual({ before, afterRefresh, after: shown() }, { - before: { shown: ['elsewhere', 'external', 'in-folder', 'native'], calls: { listed: 1, locationReads: 1 } }, - afterRefresh: { listed: 1, locationReads: 1 }, - after: ['external', 'in-folder', 'native'], - }); - }); -}); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 428f17db5e8578..c0e8a4b18ed7a7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -144,8 +144,6 @@ import type { IAgentHostFirstResponseDiagnostic } from '../../../../../../platfo import { AgentHostAuthTokenCache } from '../../../browser/agentSessions/agentHost/agentHostAuth.js'; import { AgentHostLanguageModelProvider } from '../../../browser/agentSessions/agentHost/agentHostLanguageModelProvider.js'; import { AgentHostSessionListContribution } from '../../../browser/agentSessions/agentHost/agentHostSessionListContribution.js'; -import { AgentHostWorktreeSessionSources, IAgentHostWorktreeSessionSources } from '../../../browser/agentSessions/agentHost/agentHostWorktreeSessionSources.js'; -import { IWorkspaceFolderLocationService } from '../../../../../../platform/workspace/common/folderLocations.js'; import { IComputerUseSetupService } from '../../../browser/agentSessions/agentHost/computerUseSetup.js'; import { AgentHostSessionListController } from '../../../browser/agentSessions/agentHost/agentHostSessionListController.js'; import { AgentHostSessionListStore, type IAgentHostSessionListConnection } from '../../../browser/agentSessions/agentHost/agentHostSessionListStore.js'; @@ -967,9 +965,6 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv }; instantiationService.stub(IAccessibilitySignalService, accessibilitySignalService as IAccessibilitySignalService); instantiationService.stub(IAccessibilityService, new TestAccessibilityService()); - // The window's folder has no other locations, and no session sources: worktree support adds no sessions to the list - instantiationService.stub(IWorkspaceFolderLocationService, upcastPartial({ onDidChangeProviders: Event.None, onDidChangeLocation: Event.None, canSetLocation: () => false })); - instantiationService.stub(IAgentHostWorktreeSessionSources, disposables.add(new AgentHostWorktreeSessionSources())); const agentHostService = new MockAgentHostService(); disposables.add(toDisposable(() => agentHostService.dispose())); From 43020e245c8263da0b118a583dff4a3a651fc1cc Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 15:33:02 +0100 Subject: [PATCH 123/165] Worktree support: match the sessions list by project apart from how it matches folders, so it composes with other matches Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostSessionListStore.ts | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts index 30ab965b06169d..6742b8b083396e 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts @@ -129,6 +129,18 @@ export class AgentHostSessionListStore extends Disposable { this._register(this._connection.onDidNotification(n => this._onNotification(n))); + // Matching by project narrows the list as it turns off, and widens it as it turns on, which only the host's list supplies + if (this._options.matchProjectRoot) { + this._register(runOnChange(this._options.matchProjectRoot, () => { + if (this._options.filterToWorkspace === false) { + return; + } + this._cacheValid = false; + this._filterEntriesToWorkspace(); + void this.refresh(CancellationToken.None); + })); + } + // Sessions may have changed on the host while the connection was down. if (this._connection.onDidReconnect) { this._register(this._connection.onDidReconnect(() => { @@ -148,18 +160,6 @@ export class AgentHostSessionListStore extends Disposable { this._filterEntriesToWorkspace(); void this.refresh(CancellationToken.None); })); - - // Matching by project narrows the list as it turns off, and widens it as it turns on, which only the host's list supplies - if (this._options.matchProjectRoot) { - this._register(runOnChange(this._options.matchProjectRoot, () => { - if (this._options.filterToWorkspace === false) { - return; - } - this._cacheValid = false; - this._filterEntriesToWorkspace(); - void this.refresh(CancellationToken.None); - })); - } } getSessions(provider: string): readonly IAgentHostSessionListEntry[] { @@ -536,7 +536,7 @@ export class AgentHostSessionListStore extends Disposable { if (this._options.filterToWorkspace === false) { return true; } - const inWorkspace = this._computeSessionInWorkspace(entry); + const inWorkspace = this._computeSessionInWorkspace(entry) || this._matchesProjectRoot(entry.summary); // A legacy session is matched by its repository root, which must be a local // path; a remote project (e.g. an `https://` repo URL) silently matches // nothing. Excluding one is legitimate, so only report the broken input, and @@ -548,6 +548,15 @@ export class AgentHostSessionListStore extends Disposable { return inWorkspace; } + /** Whether the session's project is a folder of the window, while matching by project (`matchProjectRoot`). */ + private _matchesProjectRoot(summary: SessionSummary): boolean { + if (!summary.project?.uri || !this._options.matchProjectRoot?.get()) { + return false; + } + const project = URI.parse(summary.project.uri); + return this._workspaceContextService.getWorkspace().folders.some(folder => extUriBiasedIgnorePathCase.isEqual(project, folder.uri)); + } + private _computeSessionInWorkspace(entry: IAgentHostSessionListEntry): boolean { const workingDirectories = this._containmentCandidates(entry.summary); const workspace = this._workspaceContextService.getWorkspace(); @@ -567,7 +576,7 @@ export class AgentHostSessionListStore extends Disposable { if (folders.length === 0) { return true; } - return this._matchesAnyFolder(workingDirectories, folders) || this._matchesProjectRoot(entry.summary, folders); + return this._matchesAnyFolder(workingDirectories, folders); } private _filterEntriesToWorkspace(): void { @@ -586,14 +595,6 @@ export class AgentHostSessionListStore extends Disposable { } } - private _matchesProjectRoot(summary: SessionSummary, folders: readonly IWorkspaceFolder[]): boolean { - if (!summary.project?.uri || !this._options.matchProjectRoot?.get()) { - return false; - } - const project = URI.parse(summary.project.uri); - return folders.some(folder => extUriBiasedIgnorePathCase.isEqual(project, folder.uri)); - } - private _matchesAnyFolder(workingDirectories: readonly URI[], folders: readonly IWorkspaceFolder[]): boolean { return workingDirectories.some(directory => folders.some(folder => extUriBiasedIgnorePathCase.isEqualOrParent(directory, folder.uri)) From 7d6f5154050a9e35883965681e7fe5594937d582 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 15:36:14 +0100 Subject: [PATCH 124/165] Worktree support: the sessions list matches by project only sessions of no multi-root workspace, which only the windows of their workspace file list Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentSessions/agentHost/agentHostSessionListStore.ts | 7 +++++-- .../agentHost/agentHostSessionListStoreProjectRoot.test.ts | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts index 6742b8b083396e..e508466edaa135 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts @@ -548,9 +548,12 @@ export class AgentHostSessionListStore extends Disposable { return inWorkspace; } - /** Whether the session's project is a folder of the window, while matching by project (`matchProjectRoot`). */ + /** + * Whether the session's project is a folder of the window, while matching by project (`matchProjectRoot`). Not a session + * of a multi-root workspace, which only the windows of its workspace file list. + */ private _matchesProjectRoot(summary: SessionSummary): boolean { - if (!summary.project?.uri || !this._options.matchProjectRoot?.get()) { + if (!summary.project?.uri || !this._options.matchProjectRoot?.get() || readSessionMultiRootMetadata(summary._meta)) { return false; } const project = URI.parse(summary.project.uri); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostSessionListStoreProjectRoot.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostSessionListStoreProjectRoot.test.ts index f1249605d53fca..113a6bddccde66 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostSessionListStoreProjectRoot.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostSessionListStoreProjectRoot.test.ts @@ -13,6 +13,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../ba import { IAgentHostConnectionsService } from '../../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { identityAgentHostResourceUriMapper } from '../../../../../../../platform/agentHost/common/agentHostUri.js'; import { IAgentSessionMetadata } from '../../../../../../../platform/agentHost/common/agentService.js'; +import { withSessionMultiRootMetadata } from '../../../../../../../platform/agentHost/common/state/sessionState.js'; import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../../platform/log/common/log.js'; import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../../platform/workspace/common/workspace.js'; @@ -22,12 +23,13 @@ suite('AgentHostSessionListStore - project root', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - const session = (id: string, workingDirectory: string, project: string): IAgentSessionMetadata => ({ + const session = (id: string, workingDirectory: string, project: string, workspaceFile?: string): IAgentSessionMetadata => ({ session: URI.from({ scheme: 'copilot', path: `/${id}` }), startTime: 0, modifiedTime: 0, workingDirectories: [URI.file(workingDirectory)], project: { uri: URI.file(project), displayName: project.split('/').pop()! }, + ...(workspaceFile ? { _meta: withSessionMultiRootMetadata(undefined, { workspaceFile: URI.file(workspaceFile).toString() }) } : {}), }); test('while matching by project root, also lists the sessions whose project is a folder of the window, such as in its worktrees, and lists them again as it turns on and off', async () => { @@ -44,6 +46,8 @@ suite('AgentHostSessionListStore - project root', () => { // Works in a worktree of the window's folder, which it reports as its project session('worktree', '/work/vscode.worktrees/feature', '/work/vscode'), session('unrelated', '/work/other.worktrees/x', '/work/other'), + // A session of another multi-root workspace that shares the folder: only the windows of its workspace file list it + session('multi-root', '/work/vscode.worktrees/multi', '/work/vscode', '/work/other.code-workspace'), ]; const connection: IAgentHostSessionListConnection = { onDidNotification: Event.None, From 4169870dca52f019997e1d4f327907df5f3939ff Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 15:37:34 +0100 Subject: [PATCH 125/165] Worktree support: test that a session of another workspace file whose project is a folder of the window is not listed by its project Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...entHostSessionListStoreProjectRoot.test.ts | 47 +++++++++++++------ 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostSessionListStoreProjectRoot.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostSessionListStoreProjectRoot.test.ts index 113a6bddccde66..82a78b79d8facf 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostSessionListStoreProjectRoot.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostSessionListStoreProjectRoot.test.ts @@ -32,23 +32,17 @@ suite('AgentHostSessionListStore - project root', () => { ...(workspaceFile ? { _meta: withSessionMultiRootMetadata(undefined, { workspaceFile: URI.file(workspaceFile).toString() }) } : {}), }); - test('while matching by project root, also lists the sessions whose project is a folder of the window, such as in its worktrees, and lists them again as it turns on and off', async () => { + /** The sessions list of a window on `folders`, of the workspace file `configuration` if any, while matching by project root as `matchProjectRoot` says. */ + function createList(sessions: readonly IAgentSessionMetadata[], options?: { readonly folders?: readonly string[]; readonly configuration?: string }) { const matchProjectRoot = observableValue('matchProjectRoot', false); const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IAgentHostConnectionsService, upcastPartial({ registerSessionResource: (backendSession: URI) => backendSession })); + const folders = (options?.folders ?? ['/work/vscode']).map(folder => upcastPartial({ uri: URI.file(folder) })); instantiationService.stub(IWorkspaceContextService, upcastPartial({ - getWorkspace: () => upcastPartial({ uri: URI.file('/work/vscode'), folders: [upcastPartial({ uri: URI.file('/work/vscode') })] }), + getWorkspace: () => upcastPartial(options?.configuration ? { configuration: URI.file(options.configuration), folders } : { uri: URI.file('/work/vscode'), folders }), onDidChangeWorkspaceFolders: Event.None, })); - const sessions = [ - session('in-folder', '/work/vscode/src', '/work/vscode'), - // Works in a worktree of the window's folder, which it reports as its project - session('worktree', '/work/vscode.worktrees/feature', '/work/vscode'), - session('unrelated', '/work/other.worktrees/x', '/work/other'), - // A session of another multi-root workspace that shares the folder: only the windows of its workspace file list it - session('multi-root', '/work/vscode.worktrees/multi', '/work/vscode', '/work/other.code-workspace'), - ]; const connection: IAgentHostSessionListConnection = { onDidNotification: Event.None, resourceUris: identityAgentHostResourceUriMapper, @@ -58,11 +52,25 @@ suite('AgentHostSessionListStore - project root', () => { dispatch: () => { }, }; const list = store.add(instantiationService.createInstance(AgentHostSessionListStore, connection, { matchProjectRoot })); - const shown = () => list.getSessions('copilot').map(entry => entry.rawId).sort(); - const settle = async () => { - await new Promise(resolve => setTimeout(resolve, 0)); - await list.refresh(CancellationToken.None); + return { + matchProjectRoot, + shown: () => list.getSessions('copilot').map(entry => entry.rawId).sort(), + settle: async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + await list.refresh(CancellationToken.None); + }, }; + } + + test('while matching by project root, also lists the sessions whose project is a folder of the window, such as in its worktrees, and lists them again as it turns on and off', async () => { + const { matchProjectRoot, shown, settle } = createList([ + session('in-folder', '/work/vscode/src', '/work/vscode'), + // Works in a worktree of the window's folder, which it reports as its project + session('worktree', '/work/vscode.worktrees/feature', '/work/vscode'), + session('unrelated', '/work/other.worktrees/x', '/work/other'), + // A session of a multi-root workspace whose project is the folder: not listed by its project in a single-folder window + session('multi-root', '/work/vscode.worktrees/multi', '/work/vscode', '/work/other.code-workspace'), + ]); await settle(); const off = shown(); @@ -78,4 +86,15 @@ suite('AgentHostSessionListStore - project root', () => { offAgain: ['in-folder'], }); }); + + test('in a window of a workspace file, a session of another workspace file whose project is one of its folders is not listed by its project', async () => { + const { matchProjectRoot, shown, settle } = createList([ + session('this-workspace', '/work/vscode.worktrees/a', '/work/vscode', '/work/this.code-workspace'), + session('other-workspace', '/work/vscode.worktrees/b', '/work/vscode', '/work/other.code-workspace'), + ], { folders: ['/work/vscode', '/work/tools'], configuration: '/work/this.code-workspace' }); + matchProjectRoot.set(true, undefined); + await settle(); + + assert.deepStrictEqual(shown(), ['this-workspace']); + }); }); From 10a4cb211113da2e62b7275c34d88dff816d0a93 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 15:38:11 +0100 Subject: [PATCH 126/165] Worktree support: the project-root test's workspace also reports workbench state changes, as the store may listen to them Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostSessionListStoreProjectRoot.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostSessionListStoreProjectRoot.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostSessionListStoreProjectRoot.test.ts index 82a78b79d8facf..e186bcf16278e1 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostSessionListStoreProjectRoot.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostSessionListStoreProjectRoot.test.ts @@ -42,6 +42,7 @@ suite('AgentHostSessionListStore - project root', () => { instantiationService.stub(IWorkspaceContextService, upcastPartial({ getWorkspace: () => upcastPartial(options?.configuration ? { configuration: URI.file(options.configuration), folders } : { uri: URI.file('/work/vscode'), folders }), onDidChangeWorkspaceFolders: Event.None, + onDidChangeWorkbenchState: Event.None, })); const connection: IAgentHostSessionListConnection = { onDidNotification: Event.None, From 660e14e01167cdf8ff7e2486ee626a7ef84d3df7 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 15:44:33 +0100 Subject: [PATCH 127/165] Worktree support: the sessions list keeps the sessions of the folder's own location while the folder is at a worktree of it Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostSessionListStore.ts | 5 ++++- .../agentHostSessionListStoreProjectRoot.test.ts | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts index e508466edaa135..4df451d3f53ccd 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts @@ -557,7 +557,10 @@ export class AgentHostSessionListStore extends Disposable { return false; } const project = URI.parse(summary.project.uri); - return this._workspaceContextService.getWorkspace().folders.some(folder => extUriBiasedIgnorePathCase.isEqual(project, folder.uri)); + const workspace = this._workspaceContextService.getWorkspace(); + // Also the folder's own location while it is at another, such as a worktree of it: the window's workspace + return workspace.folders.some(folder => extUriBiasedIgnorePathCase.isEqual(project, folder.uri)) + || (!workspace.configuration && !!workspace.uri && extUriBiasedIgnorePathCase.isEqual(project, workspace.uri)); } private _computeSessionInWorkspace(entry: IAgentHostSessionListEntry): boolean { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostSessionListStoreProjectRoot.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostSessionListStoreProjectRoot.test.ts index e186bcf16278e1..d91c864cccb2d0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostSessionListStoreProjectRoot.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostSessionListStoreProjectRoot.test.ts @@ -98,4 +98,16 @@ suite('AgentHostSessionListStore - project root', () => { assert.deepStrictEqual(shown(), ['this-workspace']); }); + test('while the window\'s folder is at one of its worktrees, still lists the sessions whose project is the folder\'s own location', async () => { + const { matchProjectRoot, shown, settle } = createList([ + session('main-checkout', '/work/vscode', '/work/vscode'), + session('sibling-worktree', '/work/vscode.worktrees/bar', '/work/vscode'), + session('at-worktree', '/work/vscode.worktrees/foo', '/work/vscode'), + session('unrelated', '/work/other.worktrees/x', '/work/other'), + ], { folders: ['/work/vscode.worktrees/foo'] }); + matchProjectRoot.set(true, undefined); + await settle(); + + assert.deepStrictEqual(shown(), ['at-worktree', 'main-checkout', 'sibling-worktree']); + }); }); From 9fea8f8d25d7530355ada281cd58927461d2d2c7 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 17:55:34 +0100 Subject: [PATCH 128/165] Sessions: a composer's chip of a window's folder and its worktrees also names the folder or worktree it works in Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/test/browser/sessionWorkspacePicker.test.ts | 4 ++-- .../sessions/browser/chat/sessionWorkspacePicker.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index de32543d336aae..8313f29736cf01 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -9029,12 +9029,12 @@ suite('WorkspacePicker - Allowed folders', () => { assert.deepStrictEqual({ atFolder, inNewWorktree, atWorktree, fromWorktree: { chip: chip(), selected: newWorktree.selected } }, { atFolder: { items: ['worktree New Worktree', 'folder local/vscode *', 'worktree a'], - chip: { icon: 'folder', label: null, ariaLabel: 'Folder: local/vscode', hover: 'Folder: local/vscode' }, + chip: { icon: 'folder', label: 'local/vscode', ariaLabel: 'Folder: local/vscode', hover: 'Folder: local/vscode' }, }, inNewWorktree: { icon: 'worktree', label: 'Worktree', ariaLabel: 'New worktree of local/vscode', hover: 'New worktree of local/vscode' }, atWorktree: { items: ['worktree New Worktree', 'folder local/vscode', 'worktree a *'], - chip: { icon: 'worktree', label: null, ariaLabel: 'Worktree: a', hover: 'Worktree: a' }, + chip: { icon: 'worktree', label: 'a', ariaLabel: 'Worktree: a', hover: 'Worktree: a' }, }, fromWorktree: { chip: { icon: 'worktree', label: 'Worktree', ariaLabel: 'New worktree of local/vscode', hover: 'New worktree of local/vscode' }, selected: true }, }); diff --git a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts index 3376c6502b40dc..3e77b0a196ab27 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts +++ b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts @@ -2505,7 +2505,7 @@ export class WorkspacePicker extends Disposable { ? localize('workspacePicker.categorySelectionAriaLabel', "{0}: {1}", options.label ?? options.ariaLabel, label) : options.ariaLabel); if (chip && badgeCount === 0) { - // By its icon only, but for New Worktree: it names the folder or worktree for screen readers + // What it is, a folder, a worktree or New Worktree, by name for screen readers trigger.setAttribute('aria-label', chip.description); } if (reflectsWorkspace) { @@ -2961,7 +2961,7 @@ export class WorkspacePicker extends Disposable { /** * The chip of a picker whose allowed folders are a window's folder and its worktrees (with a {@link IAllowedFolderPresentation.kind}): - * by its icon only, a folder or a worktree, but New Worktree by its name, with what it is for screen readers and on hover. + * by its icon, a folder or a worktree, and its name, or Worktree for New Worktree, with what it is for screen readers and on hover. */ private _getLocationChip(workspace: ISessionWorkspace, providerId: string | undefined): { readonly icon: ThemeIcon; readonly label: string | undefined; readonly description: string } | undefined { const folderUri = workspace.folders[0]?.root; @@ -2978,8 +2978,8 @@ export class WorkspacePicker extends Disposable { return { icon: WORKSPACE_PICKER_WORKTREE_ICON, label: localize('workspacePicker.newWorktreeChip', "Worktree"), description: localize('workspacePicker.newWorktreeChipDescription', "New worktree of {0}", of) }; } return presentation.kind === 'worktree' - ? { icon: WORKSPACE_PICKER_WORKTREE_ICON, label: undefined, description: localize('workspacePicker.worktreeChipDescription', "Worktree: {0}", name) } - : { icon: presentation.icon ?? Codicon.folder, label: undefined, description: localize('workspacePicker.folderChipDescription', "Folder: {0}", name) }; + ? { icon: WORKSPACE_PICKER_WORKTREE_ICON, label: name, description: localize('workspacePicker.worktreeChipDescription', "Worktree: {0}", name) } + : { icon: presentation.icon ?? Codicon.folder, label: name, description: localize('workspacePicker.folderChipDescription', "Folder: {0}", name) }; } /** What the chip of a picker restricted to a window's folder and its worktrees shows, by name: for its hover. */ From 8d7f9f24707b4d0690fa526dc83fad353a857f57 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 18:02:54 +0100 Subject: [PATCH 129/165] Sessions: a composer's chip shows New Worktree by that name Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/test/browser/sessionWorkspacePicker.test.ts | 8 ++++---- .../sessions/browser/chat/sessionWorkspacePicker.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index 8313f29736cf01..7e923d6136ff0f 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -8998,7 +8998,7 @@ suite('WorkspacePicker - Allowed folders', () => { }); }); - test('with a window\'s folder and its worktrees, shows New Worktree and the worktrees by the worktree icon, and its chip by its icon only, naming it for screen readers and on hover, but New Worktree as Worktree, also from a worktree', async () => { + test('with a window\'s folder and its worktrees, shows New Worktree and the worktrees by the worktree icon, and its chip by its icon and name, or New Worktree by that name, also from a worktree, naming what it is for screen readers and on hover', async () => { const newWorktree = { selected: false }; const { picker } = createPicker(['/local/vscode', '/local/vscode.worktrees/a'], undefined, { getAllowedFolderPresentation: folderUri => folderUri.path === '/local/vscode' @@ -9031,12 +9031,12 @@ suite('WorkspacePicker - Allowed folders', () => { items: ['worktree New Worktree', 'folder local/vscode *', 'worktree a'], chip: { icon: 'folder', label: 'local/vscode', ariaLabel: 'Folder: local/vscode', hover: 'Folder: local/vscode' }, }, - inNewWorktree: { icon: 'worktree', label: 'Worktree', ariaLabel: 'New worktree of local/vscode', hover: 'New worktree of local/vscode' }, + inNewWorktree: { icon: 'worktree', label: 'New Worktree', ariaLabel: 'New worktree of local/vscode', hover: 'New worktree of local/vscode' }, atWorktree: { items: ['worktree New Worktree', 'folder local/vscode', 'worktree a *'], chip: { icon: 'worktree', label: 'a', ariaLabel: 'Worktree: a', hover: 'Worktree: a' }, }, - fromWorktree: { chip: { icon: 'worktree', label: 'Worktree', ariaLabel: 'New worktree of local/vscode', hover: 'New worktree of local/vscode' }, selected: true }, + fromWorktree: { chip: { icon: 'worktree', label: 'New Worktree', ariaLabel: 'New worktree of local/vscode', hover: 'New worktree of local/vscode' }, selected: true }, }); }); @@ -9063,7 +9063,7 @@ suite('WorkspacePicker - Allowed folders', () => { assert.deepStrictEqual({ atWorktree, inNewWorktree: { checked: checked(), label: trigger.querySelector('.sessions-chat-dropdown-label')?.textContent, selected: picker.selectedFolderUri?.path } }, { atWorktree: { selected: '/local/vscode.worktrees/a', events: ['change /local/vscode.worktrees/a', 'select /local/vscode.worktrees/a'], checked: ['/local/vscode.worktrees/a'], ariaLabel: 'Worktree: a', worktreeIcon: true }, - inNewWorktree: { checked: ['New Worktree'], label: 'Worktree', selected: '/local/vscode.worktrees/a' }, + inNewWorktree: { checked: ['New Worktree'], label: 'New Worktree', selected: '/local/vscode.worktrees/a' }, }); }); diff --git a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts index 3e77b0a196ab27..486d35d2954cce 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts +++ b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts @@ -2961,7 +2961,7 @@ export class WorkspacePicker extends Disposable { /** * The chip of a picker whose allowed folders are a window's folder and its worktrees (with a {@link IAllowedFolderPresentation.kind}): - * by its icon, a folder or a worktree, and its name, or Worktree for New Worktree, with what it is for screen readers and on hover. + * by its icon, a folder or a worktree, and its name, or New Worktree by that name, with what it is for screen readers and on hover. */ private _getLocationChip(workspace: ISessionWorkspace, providerId: string | undefined): { readonly icon: ThemeIcon; readonly label: string | undefined; readonly description: string } | undefined { const folderUri = workspace.folders[0]?.root; @@ -2975,7 +2975,7 @@ export class WorkspacePicker extends Disposable { const primary = presentation.kind === 'worktree' ? this._getAllowedFolders()?.find(candidate => this.options.getAllowedFolderPresentation?.(candidate)?.kind === 'folder') : undefined; const primaryResolved = primary ? this._resolveFolder(primary) : undefined; const of = primary && primaryResolved ? this.options.getAllowedFolderPresentation?.(primary)?.label ?? this._getFolderWorkspaceLabel(primaryResolved.workspace, primaryResolved.providerId) : name; - return { icon: WORKSPACE_PICKER_WORKTREE_ICON, label: localize('workspacePicker.newWorktreeChip', "Worktree"), description: localize('workspacePicker.newWorktreeChipDescription', "New worktree of {0}", of) }; + return { icon: WORKSPACE_PICKER_WORKTREE_ICON, label: getNewWorktreeLabel(), description: localize('workspacePicker.newWorktreeChipDescription', "New worktree of {0}", of) }; } return presentation.kind === 'worktree' ? { icon: WORKSPACE_PICKER_WORKTREE_ICON, label: name, description: localize('workspacePicker.worktreeChipDescription', "Worktree: {0}", name) } From 2c1fec37173cc10acddad84cc992d1c358017b0c Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 18:04:27 +0100 Subject: [PATCH 130/165] Worktree support: the folder chip shows New Worktree by that name Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentSessions/agentHost/agentHostWorktreeFolderChoices.ts | 2 +- .../agentHost/agentHostWorktreeFolderChoices.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts index 4b1f388b88b6de..bc5f5f7cd47e3f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts @@ -128,7 +128,7 @@ export class AgentHostWorktreeFolderChoices extends Disposable { const choice = choices.find(candidate => extUriBiasedIgnorePathCase.isEqual(candidate.uri, folder)); const primary = choices.find(candidate => candidate.kind === 'folder'); if (this.isNewWorktreeSelected()) { - return { icon: WORKTREE_ICON, label: localize('agentHost.folderPicker.newWorktreeChip', "Worktree"), description: localize('agentHost.folderPicker.newWorktreeChipDescription', "New worktree of {0}", primary?.label ?? basename(folder)) }; + return { icon: WORKTREE_ICON, label: getNewWorktreeLabel(), description: localize('agentHost.folderPicker.newWorktreeChipDescription', "New worktree of {0}", primary?.label ?? basename(folder)) }; } const name = choice?.label ?? basename(folder); // A folder it does not list, such as the worktree a started session works in, is a worktree once git proves it one diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts index e63ac6f599fed6..14344ed22a6224 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts @@ -109,7 +109,7 @@ suite('AgentHostWorktreeFolderChoices', () => { chip(unlisted.primaryAlias); await new Promise(resolve => setTimeout(resolve, 0)); assert.deepStrictEqual({ inNewWorktree, changes, folderChip, worktreeChip: chip(feature), unlistedBeforeProven, unlistedWorktreeChip: chip(unlisted.worktree), otherRepositoryChip: chip(unlisted.otherRepository), primaryAliasChip: chip(unlisted.primaryAlias) }, { - inNewWorktree: { items: ['New Worktree *', '[Folder] vscode (Current)', '[Worktrees] feature', '[Worktrees] other'], chip: { icon: 'worktree', label: 'Worktree', description: 'New worktree of vscode' } }, + inNewWorktree: { items: ['New Worktree *', '[Folder] vscode (Current)', '[Worktrees] feature', '[Worktrees] other'], chip: { icon: 'worktree', label: 'New Worktree', description: 'New worktree of vscode' } }, changes: ['isolation worktree', 'folder /work/vscode.worktrees/feature', 'isolation folder'], folderChip: { icon: 'folder', label: null, description: 'Folder: vscode' }, worktreeChip: { icon: 'worktree', label: null, description: 'Worktree: feature' }, From f9ca40aa1299691c59ad6f26badac73e83d2aedb Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 18:39:01 +0100 Subject: [PATCH 131/165] Worktree support: the folder chip leads the chat input's pickers, as the new-session composer leads with its workspace Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostChatInputPicker.contribution.ts | 7 ++++++- .../agentSessions/agentHostChatContribution.test.ts | 13 +++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts index b16bc4869cfb55..91e25f858135e5 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts @@ -28,6 +28,10 @@ const modeAndPermissionsWhen = ContextKeyExpr.and( * menu by VS Code's menu/toolbar convention. * * Order layout in the secondary toolbar (existing items annotated): + * -1 OpenAgentHostFolderPickerAction (NEW — Folder, with worktree support + * in a single-folder window: first, + * as the new-session composer leads + * with its workspace) * 0 OpenSessionTargetPickerAction (Copilot CLI - Agent Host) * 0.5 OpenDelegationPickerAction * 0.7 OpenAgentHostModePickerAction (NEW — Mode) @@ -72,7 +76,8 @@ export class OpenAgentHostFolderPickerAction extends Action2 { }, { id: MenuId.ChatInputSecondary, group: 'navigation', - order: 1.1, + // First, before the session type, as the new-session composer leads with its workspace + order: -1, // With worktree support, a window with a single local folder offers New Worktree, the folder and its worktrees when: ContextKeyExpr.and( ChatContextKeys.enabled, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index c0e8a4b18ed7a7..9a1293272bacb8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -7988,6 +7988,19 @@ suite('AgentHostChatContribution', () => { }, { carried: '/workspace/b', windowDefault: '/workspace/a' }); })); + test('with worktree support, the folder picker leads the chat input\'s pickers; without it, the multi-root folder picker stays last', () => { + const items = MenuRegistry.getMenuItems(MenuId.ChatInputSecondary).filter((i): i is IMenuItem => isIMenuItem(i) && i.group === 'navigation'); + const folderItems = items.filter(i => i.command.id === OpenAgentHostFolderPickerAction.ID); + const worktree = folderItems.find(i => !!i.when?.keys().includes(`config.${ChatConfiguration.EditorWindowEnableWorktree}`)); + const multiRoot = folderItems.find(i => i !== worktree); + const others = items.filter(i => i.command.id !== OpenAgentHostFolderPickerAction.ID).map(i => i.order ?? 0); + + assert.deepStrictEqual({ + worktreeFirst: (worktree?.order ?? 0) < Math.min(...others), + multiRootOrder: multiRoot?.order, + }, { worktreeFirst: true, multiRootOrder: 1.1 }); + }); + test('folder picker is visible only in multi-root agent-host editor windows when the provider pins an immutable primary directory', () => { const item = MenuRegistry.getMenuItems(MenuId.ChatInputSecondary) .find((i): i is IMenuItem => isIMenuItem(i) && i.command.id === OpenAgentHostFolderPickerAction.ID); From 5886b54159e0d782d6364925e7a9e694a19b563e Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 18:47:27 +0100 Subject: [PATCH 132/165] Worktree support: the folder chip shows a worktree that VS Code created for the folder as one at once, rather than as a folder until it is read Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostWorktreeFolderChoices.ts | 7 +++++++ .../agentHostWorktreeFolderChoices.test.ts | 14 ++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts index bc5f5f7cd47e3f..5583c2782e9aef 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts @@ -10,6 +10,8 @@ import { onUnexpectedError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { basename, extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; +import { Schemas } from '../../../../../../base/common/network.js'; +import { getRepositoryRootFromWorktree } from '../../../../../../platform/agentHost/common/worktreePaths.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; @@ -217,6 +219,11 @@ export class AgentHostWorktreeFolderChoices extends Disposable { if (!primary) { return false; } + // A worktree that VS Code created for the folder, `.worktrees/`, is one at once, also before it is read + const repository = folder.scheme === Schemas.file ? getRepositoryRootFromWorktree(folder) : undefined; + if (repository && extUriBiasedIgnorePathCase.isEqual(repository, primary)) { + return true; + } const key = `${primary.toString()}\n${folder.toString()}`; const known = this._provenWorktrees.get(key); if (known === undefined) { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts index 14344ed22a6224..732c4f0c225b0c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts @@ -52,7 +52,7 @@ suite('AgentHostWorktreeFolderChoices', () => { getLocations: async () => listed.locations.map(uri => upcastPartial({ uri, label: uri.path.split('/').pop() })), getLocation: () => options.at.path === primary.path ? undefined : upcastPartial({ uri: options.at, label: options.at.path.split('/').pop() }), // As git links them: only the worktrees of the window's folder resolve, not another repository or the folder itself - resolveLocation: async (_primary: URI, location: URI) => options.resolveLocation ? options.resolveLocation(location) : location.path.startsWith('/work/vscode.worktrees/') ? upcastPartial({ uri: location }) : undefined, + resolveLocation: async (_primary: URI, location: URI) => options.resolveLocation ? options.resolveLocation(location) : location.path.startsWith('/work/vscode.worktrees/') || location.path.startsWith('/work/checkouts/') ? upcastPartial({ uri: location }) : undefined, })); instantiationService.stub(IAgentHostNewSessionFolderService, upcastPartial({ getFolder: () => undefined, @@ -103,25 +103,27 @@ suite('AgentHostWorktreeFolderChoices', () => { // By its icon only, but New Worktree by its name; what it is, by name, for screen readers and on hover // An existing chat, whose started session works in a folder that the chip does not list: a worktree once git proves it // one; another repository, or the window's folder by another path, is a folder - const unlisted = { worktree: URI.file('/work/vscode.worktrees/small-random-change-addition'), otherRepository: URI.file('/work/another-repo'), primaryAlias: URI.file('/private/work/vscode') }; - const unlistedBeforeProven = chip(unlisted.worktree).icon; + // A worktree VS Code created for the folder is one at once, as after a reload; one elsewhere once git proves it + const unlisted = { worktree: URI.file('/work/vscode.worktrees/small-random-change-addition'), elsewhere: URI.file('/work/checkouts/fix'), otherRepository: URI.file('/work/another-repo'), primaryAlias: URI.file('/private/work/vscode') }; + const beforeRead = { worktree: chip(unlisted.worktree).icon, elsewhere: chip(unlisted.elsewhere).icon }; chip(unlisted.otherRepository); chip(unlisted.primaryAlias); await new Promise(resolve => setTimeout(resolve, 0)); - assert.deepStrictEqual({ inNewWorktree, changes, folderChip, worktreeChip: chip(feature), unlistedBeforeProven, unlistedWorktreeChip: chip(unlisted.worktree), otherRepositoryChip: chip(unlisted.otherRepository), primaryAliasChip: chip(unlisted.primaryAlias) }, { + assert.deepStrictEqual({ inNewWorktree, changes, folderChip, worktreeChip: chip(feature), beforeRead, unlistedWorktreeChip: chip(unlisted.worktree), elsewhereChip: chip(unlisted.elsewhere), otherRepositoryChip: chip(unlisted.otherRepository), primaryAliasChip: chip(unlisted.primaryAlias) }, { inNewWorktree: { items: ['New Worktree *', '[Folder] vscode (Current)', '[Worktrees] feature', '[Worktrees] other'], chip: { icon: 'worktree', label: 'New Worktree', description: 'New worktree of vscode' } }, changes: ['isolation worktree', 'folder /work/vscode.worktrees/feature', 'isolation folder'], folderChip: { icon: 'folder', label: null, description: 'Folder: vscode' }, worktreeChip: { icon: 'worktree', label: null, description: 'Worktree: feature' }, - unlistedBeforeProven: 'folder', + beforeRead: { worktree: 'worktree', elsewhere: 'folder' }, unlistedWorktreeChip: { icon: 'worktree', label: null, description: 'Worktree: small-random-change-addition' }, + elsewhereChip: { icon: 'worktree', label: null, description: 'Worktree: fix' }, otherRepositoryChip: { icon: 'folder', label: null, description: 'Folder: another-repo' }, primaryAliasChip: { icon: 'folder', label: null, description: 'Folder: vscode' }, }); }); test('a folder it does not list, which a failed read does not prove a worktree, is read again as it reads the worktrees again, and shows as a worktree once proven', async () => { - const unlisted = URI.file('/work/vscode.worktrees/small-random-change-addition'); + const unlisted = URI.file('/work/checkouts/fix'); let reads = 0; let readable = false; const { choices } = await createChoices({ From 95aac71aba256fd6720943080d83946df0b47494 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 18:57:14 +0100 Subject: [PATCH 133/165] Worktree support: a folder named like a worktree of the window's folder shows as one only until git proves what it is Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostWorktreeFolderChoices.ts | 12 +++---- .../agentHostWorktreeFolderChoices.test.ts | 35 ++++++++++++++++++- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts index 5583c2782e9aef..36b860eb93e8d4 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts @@ -212,18 +212,16 @@ export class AgentHostWorktreeFolderChoices extends Disposable { /** * Whether `folder` is a worktree of the window's folder, as its location providers resolve it, such as by git's links - * between them: not yet known, it is not, and the chip shows it again once it is. + * between them. Until they answer, a folder named like a worktree VS Code creates for the window's folder, + * `.worktrees/`, is assumed to be one and any other folder is not; the chip shows the answer once it settles. */ private _isProvenWorktree(folder: URI): boolean { const primary = this._workspaceContextService.getWorkspace().uri; if (!primary) { return false; } - // A worktree that VS Code created for the folder, `.worktrees/`, is one at once, also before it is read const repository = folder.scheme === Schemas.file ? getRepositoryRootFromWorktree(folder) : undefined; - if (repository && extUriBiasedIgnorePathCase.isEqual(repository, primary)) { - return true; - } + const assumed = !!repository && extUriBiasedIgnorePathCase.isEqual(repository, primary); const key = `${primary.toString()}\n${folder.toString()}`; const known = this._provenWorktrees.get(key); if (known === undefined) { @@ -234,7 +232,7 @@ export class AgentHostWorktreeFolderChoices extends Disposable { return; } this._provenWorktrees.set(key, worktree); - if (worktree) { + if (worktree !== assumed) { this._onDidChange.fire(); } }; @@ -243,7 +241,7 @@ export class AgentHostWorktreeFolderChoices extends Disposable { onUnexpectedError(error); }); } - return known === true; + return known === undefined || known === 'resolving' ? assumed : known; } /** The session's isolation property and values, with whether it can work in a new worktree. */ diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts index 732c4f0c225b0c..78a037e84278c6 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts @@ -103,7 +103,7 @@ suite('AgentHostWorktreeFolderChoices', () => { // By its icon only, but New Worktree by its name; what it is, by name, for screen readers and on hover // An existing chat, whose started session works in a folder that the chip does not list: a worktree once git proves it // one; another repository, or the window's folder by another path, is a folder - // A worktree VS Code created for the folder is one at once, as after a reload; one elsewhere once git proves it + // A folder named like a worktree VS Code creates for the folder shows as one before it is read, as after a reload; one elsewhere once git proves it const unlisted = { worktree: URI.file('/work/vscode.worktrees/small-random-change-addition'), elsewhere: URI.file('/work/checkouts/fix'), otherRepository: URI.file('/work/another-repo'), primaryAlias: URI.file('/private/work/vscode') }; const beforeRead = { worktree: chip(unlisted.worktree).icon, elsewhere: chip(unlisted.elsewhere).icon }; chip(unlisted.otherRepository); @@ -148,6 +148,39 @@ suite('AgentHostWorktreeFolderChoices', () => { assert.deepStrictEqual({ failed, afterFailure, pending, proven: icon(), reads }, { failed: 'folder', afterFailure: ['folder', 'folder'], pending: 'folder', proven: 'worktree', reads: 2 }); }); + test('a folder named like a worktree VS Code creates for the folder shows as one until it is read, then as what git proves it', async () => { + const real = URI.file('/work/vscode.worktrees/real'); + const notWorktree = URI.file('/work/vscode.worktrees/not-a-worktree'); + // The window's folder by a link under the same layout: git does not prove it a worktree of the folder + const primaryLink = URI.file('/work/vscode.worktrees/primary-link'); + const flaky = URI.file('/work/vscode.worktrees/flaky'); + let flakyReadable = false; + const { choices } = await createChoices({ + enabled: true, at: primary, resolveLocation: async location => { + const proven = location.path === real.path || (location.path === flaky.path && flakyReadable); + return proven ? upcastPartial({ uri: location }) : undefined; + }, + }); + let changes = 0; + store.add(choices.onDidChange(() => changes++)); + const settle = () => new Promise(resolve => setTimeout(resolve, 0)); + const icons = () => [real, notWorktree, primaryLink, flaky].map(folder => choices.getChip(folder).icon.id); + const pending = icons(); + await settle(); + const settled = { icons: icons(), changes, description: choices.getChip(notWorktree).description }; + flakyReadable = true; + choices.refresh(); + const pendingAgain = icons(); + await settle(); + + assert.deepStrictEqual({ pending, settled, pendingAgain, reread: icons() }, { + pending: ['worktree', 'worktree', 'worktree', 'worktree'], + settled: { icons: ['worktree', 'folder', 'folder', 'folder'], changes: 3, description: 'Folder: not-a-worktree' }, + pendingAgain: ['worktree', 'worktree', 'worktree', 'worktree'], + reread: ['worktree', 'folder', 'folder', 'worktree'], + }); + }); + test('is active only with the setting on in a window with a single local folder', async () => { assert.deepStrictEqual({ on: (await createChoices({ enabled: true, at: primary })).choices.isActive(), From 6b6eecfc9750b969233e7de8ca54b1db7439975d Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 19:02:11 +0100 Subject: [PATCH 134/165] Worktree support: the Chat view's folder chip offers No Workspace while it lists sessions, and its old list shows chats without a workspace Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/agentHostModePicker.fixture.ts | 2 +- .../agentHost/agentHostChatInputPicker.ts | 3 ++ .../agentHostFolderPickerActionItem.ts | 14 +++-- .../agentHost/agentHostGenericConfigChips.ts | 3 ++ .../agentHostNewSessionFolderService.ts | 38 +++++++++++-- .../agentHost/agentHostSessionHandler.ts | 3 ++ .../agentHostSessionListController.ts | 9 +++- .../agentHost/agentHostSessionListStore.ts | 11 ++-- ...ntHostUntitledProvisionalSessionService.ts | 3 +- .../agentHostWorktreeFolderChoices.ts | 54 ++++++++++++++++++- ...entHostSessionListStoreProjectRoot.test.ts | 28 +++++++++- .../agentHostWorktreeFolderChoices.test.ts | 33 ++++++++++-- .../agentHostChatContribution.test.ts | 54 +++++++++++++++++++ .../agentHostChatInputPicker.test.ts | 2 +- .../agentHostGenericConfigChips.test.ts | 4 +- .../agentHostNewSessionFolderService.test.ts | 21 ++++++++ ...tUntitledProvisionalSessionService.test.ts | 23 ++++++++ 17 files changed, 283 insertions(+), 22 deletions(-) diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostModePicker.fixture.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostModePicker.fixture.ts index 9ef564403143fe..0d9ae9d422b554 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostModePicker.fixture.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostModePicker.fixture.ts @@ -239,7 +239,7 @@ async function render(context: ComponentFixtureContext, mode: string, permission }, }); instantiationService.stub(IAgentHostSessionWorkingDirectoryResolver, { resolve: () => undefined }); - instantiationService.stub(IAgentHostNewSessionFolderService, { getFolder: () => undefined, getDefaultFolder: () => undefined }); + instantiationService.stub(IAgentHostNewSessionFolderService, { getFolder: () => undefined, getDefaultFolder: () => undefined, isNoWorkspace: () => false }); instantiationService.stub(IAgentHostUntitledProvisionalSessionService, { onDidChange: Event.None, get: () => undefined, getResolvedConfig: () => config, refreshResolvedConfig: async () => { } }); instantiationService.set(IChatSessionsService, new class extends mock() { }()); const widget = new class extends mock() { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts index f62765f43a30fa..116c4db4d38a37 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts @@ -1400,6 +1400,9 @@ export class AgentHostChatInputPicker extends Disposable { return typeof cwd === 'string' ? URI.parse(cwd) : cwd; } const sessionResource = this._widget.viewModel?.sessionResource; + if (sessionResource && this._newSessionFolderService.isNoWorkspace(sessionResource)) { + return undefined; + } return (sessionResource && this._newSessionFolderService.getFolder(sessionResource)) ?? (sessionResource && this._workingDirectoryResolver.resolve(sessionResource)) ?? this._newSessionFolderService.getDefaultFolder() diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts index 92a40dd6ce7840..c8abd0ecfc6b8a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts @@ -147,6 +147,9 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt private _selectedFolder(): URI | undefined { const folders = this._workspaceContextService.getWorkspace().folders; const sessionResource = this._sessionResource(); + if (sessionResource && this._newSessionFolderService.isNoWorkspace(sessionResource)) { + return undefined; + } const stored = sessionResource ? this._newSessionFolderService.getFolder(sessionResource) : undefined; // Stale workspace selections are cleared by the folder service; standalone selections remain valid. if (stored) { @@ -169,15 +172,16 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt protected override renderLabel(element: HTMLElement): IDisposable | null { const selected = this._selectedFolder(); const folder = selected && this._workspaceContextService.getWorkspace().folders.find(f => f.uri.toString() === selected.toString()); - // With worktree support, by its icon only, a folder or a worktree, but New Worktree by its name - const chip = selected && this._worktree?.isActive() ? this._worktree.getChip(selected) : undefined; + // With worktree support, by its icon only, a folder or a worktree, but New Worktree and No Workspace by their names + const chip = this._worktree?.isActive() && this._worktree.isNoWorkspaceSelected() ? this._worktree.getNoWorkspaceChip() + : selected && this._worktree?.isActive() ? this._worktree.getChip(selected) : undefined; const label = chip ? chip.description : folder ? folder.name : (selected ? basename(selected) : localize('agentHost.selectFolder', "Folder")); const shownLabel = chip ? chip.label : label; const compact = this.pickerOptions.compact.get(); element.classList.toggle('icon-only', compact || !shownLabel); dom.reset( element, - ...renderLabelWithIcons(chip?.icon === Codicon.worktree ? `$(${Codicon.worktree.id})` : `$(folder-compact)`), + ...renderLabelWithIcons(chip?.icon === Codicon.worktree || chip?.icon === Codicon.comment ? `$(${chip.icon.id})` : `$(folder-compact)`), ...(!compact && shownLabel ? [dom.$('span.chat-input-picker-label', undefined, shownLabel)] : []), ); // Set the aria label after the visible text is in place: the base class @@ -213,6 +217,10 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt * Returns `undefined` when no folder is selected so no hover is shown. */ private _buildHoverContent(): IManagedHoverTooltipMarkdownString | undefined { + if (this._worktree.isActive() && this._worktree.isNoWorkspaceSelected()) { + const { description } = this._worktree.getNoWorkspaceChip(); + return { markdown: new MarkdownString().appendText(description), markdownNotSupportedFallback: description }; + } const selected = this._selectedFolder(); if (!selected) { return undefined; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostGenericConfigChips.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostGenericConfigChips.ts index dbee909a8f8b5f..22054d258d3c8b 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostGenericConfigChips.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostGenericConfigChips.ts @@ -214,6 +214,9 @@ export class AgentHostGenericConfigChips extends Disposable { return typeof cwd === 'string' ? URI.parse(cwd) : cwd; } const sessionResource = this._widget.viewModel?.sessionResource; + if (sessionResource && this._newSessionFolderService.isNoWorkspace(sessionResource)) { + return undefined; + } return (sessionResource && this._newSessionFolderService.getFolder(sessionResource)) ?? (sessionResource && this._workingDirectoryResolver.resolve(sessionResource)) ?? this._newSessionFolderService.getDefaultFolder() diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostNewSessionFolderService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostNewSessionFolderService.ts index 5d4feb7c5ecf5d..ee9d9fab7c9faa 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostNewSessionFolderService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostNewSessionFolderService.ts @@ -5,7 +5,7 @@ import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; -import { ResourceMap } from '../../../../../../base/common/map.js'; +import { ResourceMap, ResourceSet } from '../../../../../../base/common/map.js'; import { extUriBiasedIgnorePathCase, isEqual, type IExtUri } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -200,6 +200,19 @@ export interface IAgentHostNewSessionFolderService { */ setFolder(sessionResource: URI, folder: URI, options?: { readonly updateDefault?: boolean }): void; + /** + * Whether the user chose No Workspace for the given session resource, so that it + * starts without a working directory, in a scratch folder the agent host creates. + */ + isNoWorkspace(sessionResource: URI): boolean; + + /** + * Record that the given session resource starts without a working directory, + * replacing any folder chosen for it. Fires {@link onDidChangeFolder} when the + * value actually changes. Unlike a folder, it never becomes the window's default. + */ + setNoWorkspace(sessionResource: URI): void; + /** * Forget any choice recorded for the given session resource. */ @@ -233,6 +246,7 @@ export class AgentHostNewSessionFolderService extends Disposable implements IAge declare readonly _serviceBrand: undefined; private readonly _folders = new ResourceMap(); + private readonly _noWorkspace = new ResourceSet(); /** * The most recently chosen folder in this window. Window-level "sticky" @@ -292,15 +306,30 @@ export class AgentHostNewSessionFolderService extends Disposable implements IAge this._defaultFolder = folder; } const existing = this._folders.get(sessionResource); - if (existing?.toString() === folder.toString()) { + const wasNoWorkspace = this._noWorkspace.delete(sessionResource); + if (existing?.toString() === folder.toString() && !wasNoWorkspace) { return; } this._folders.set(sessionResource, folder); this._onDidChangeFolder.fire(sessionResource); } + isNoWorkspace(sessionResource: URI): boolean { + return this._noWorkspace.has(sessionResource); + } + + setNoWorkspace(sessionResource: URI): void { + if (this._noWorkspace.has(sessionResource)) { + return; + } + this._folders.delete(sessionResource); + this._noWorkspace.add(sessionResource); + this._onDidChangeFolder.fire(sessionResource); + } + clear(sessionResource: URI): void { - if (this._folders.delete(sessionResource)) { + const hadFolder = this._folders.delete(sessionResource); + if (this._noWorkspace.delete(sessionResource) || hadFolder) { this._onDidChangeFolder.fire(sessionResource); } } @@ -314,6 +343,9 @@ export class AgentHostNewSessionFolderService extends Disposable implements IAge } resolveNewSessionPrimary(sessionResource: URI): URI | undefined { + if (this._noWorkspace.has(sessionResource)) { + return undefined; + } const folders = this._workspaceContextService.getWorkspace().folders; // An explicit choice is honored only while it is still a workspace folder; // the chip records only workspace folders, so a removed one is skipped here diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 105abb0feeb678..60a99361ca6f07 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -8047,6 +8047,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (configured === null) { return undefined; } + if (configured === undefined && this._newSessionFolderService?.isNoWorkspace(sessionResource)) { + return undefined; + } return configured ?? this._newSessionFolderService?.getFolder(sessionResource) ?? this._workingDirectoryResolver?.resolve(sessionResource) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts index 95c90a8e974cad..01c6bd245c4441 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts @@ -150,7 +150,10 @@ export class AgentHostSessionListController extends Disposable implements IChatS // resource, flickering the chip back to the first folder. Carrying a // folder forward is not a new choice, so the window's default stays as // it is (a folder chosen on the user's behalf must not become it). - if (workingDirectory) { + const noWorkspace = this._newSessionFolderService.isNoWorkspace(request.untitledResource); + if (noWorkspace) { + this._newSessionFolderService.setNoWorkspace(item.resource); + } else if (workingDirectory) { this._newSessionFolderService.setFolder(item.resource, workingDirectory, { updateDefault: false }); } // Carry any imported ("Continue in…") conversation snapshot from the @@ -181,7 +184,9 @@ export class AgentHostSessionListController extends Disposable implements IChatS : this._newSessionFolderService.getFolder(request.untitledResource) ?? this._newSessionFolderService.getDefaultFolder() ?? this._workspaceContextService.getWorkspace().folders[0]?.uri; - if (currentDirectory) { + if (noWorkspace) { + this._newSessionFolderService.setNoWorkspace(nextResource); + } else if (currentDirectory) { this._newSessionFolderService.setFolder(nextResource, currentDirectory, { updateDefault: false }); } this._newSessionFolderService.clear(previousResource); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts index 4df451d3f53ccd..d16d5b3e34e725 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts @@ -12,7 +12,7 @@ import { AgentSession, type IAgentSessionMetadata } from '../../../../../../plat import { IAgentHostConnectionsService, IAgentHostSessionSchemeAlias } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import type { IAgentHostResourceUriMapper } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { ActionType, type IIsArchivedChangedAction, type IIsReadChangedAction, type INotification, type SessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; -import { readSessionMatchesByProjectRoot, readSessionMultiRootMetadata, SessionStatus, type SessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { readSessionMatchesByProjectRoot, readSessionMultiRootMetadata, readSessionWorkspaceless, SessionStatus, type SessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IWorkspaceContextService, type IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { Schemas } from '../../../../../../base/common/network.js'; @@ -117,7 +117,7 @@ export class AgentHostSessionListStore extends Disposable { readonly retainSession?: (session: URI) => boolean; /** * Also lists a session whose project (repository root) is a folder of the window, such as one working in a - * worktree of it, while true. + * worktree of it, and a session without a workspace, such as a quick chat, while true. */ readonly matchProjectRoot?: IObservable; } = {}, @@ -536,7 +536,7 @@ export class AgentHostSessionListStore extends Disposable { if (this._options.filterToWorkspace === false) { return true; } - const inWorkspace = this._computeSessionInWorkspace(entry) || this._matchesProjectRoot(entry.summary); + const inWorkspace = this._computeSessionInWorkspace(entry) || this._matchesProjectRoot(entry.summary) || this._matchesWorkspaceless(entry.summary); // A legacy session is matched by its repository root, which must be a local // path; a remote project (e.g. an `https://` repo URL) silently matches // nothing. Excluding one is legitimate, so only report the broken input, and @@ -563,6 +563,11 @@ export class AgentHostSessionListStore extends Disposable { || (!workspace.configuration && !!workspace.uri && extUriBiasedIgnorePathCase.isEqual(project, workspace.uri)); } + /** Whether the session has no workspace, which every window lists while matching by project (`matchProjectRoot`). */ + private _matchesWorkspaceless(summary: SessionSummary): boolean { + return !!this._options.matchProjectRoot?.get() && readSessionWorkspaceless(summary._meta) && !readSessionMultiRootMetadata(summary._meta); + } + private _computeSessionInWorkspace(entry: IAgentHostSessionListEntry): boolean { const workingDirectories = this._containmentCandidates(entry.summary); const workspace = this._workspaceContextService.getWorkspace(); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts index b77da0cff68c91..d2a8a9c62481fa 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts @@ -385,7 +385,8 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple this._register(this._newSessionFolderService.onDidChangeFolder(sessionResource => { const folder = this._newSessionFolderService.getFolder(sessionResource); const entry = this._entries.get(sessionResource); - if (folder && entry && !entry.disposed && entry.ownsBackend) { + // No Workspace recreates it without a working directory, as the agent host then gives it a scratch folder + if ((folder || this._newSessionFolderService.isNoWorkspace(sessionResource)) && entry && !entry.disposed && entry.ownsBackend) { void this._changeWorkingDirectory(sessionResource, folder); } })); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts index 36b860eb93e8d4..d2d6bc3826fd40 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts @@ -32,6 +32,14 @@ import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitled /** What the folder chip shows for a folder, and which one it is. */ /** The icon of a worktree in the folder chip: New Worktree, the window's folder's worktrees, and the chip at one. */ const WORKTREE_ICON = Codicon.worktree; +/** As the Agents window shows a session without a workspace. */ +const NO_WORKSPACE_ICON = Codicon.comment; +/** The setting that shows the Agents window's sessions, with their workspace-less chats, in the Chat view. */ +const SESSIONS_LIST_ENABLED_SETTING = 'chat.viewSessions.sessionsList.enabled'; + +function getNoWorkspaceLabel(): string { + return localize('agentHost.folderPicker.noWorkspace', "No Workspace"); +} interface IFolderChoice { readonly uri: URI; @@ -115,6 +123,22 @@ export class AgentHostWorktreeFolderChoices extends Disposable { }, onUnexpectedError); } + /** Whether the chip offers No Workspace: while the Chat view lists sessions with their workspace-less chats. */ + isNoWorkspaceAvailable(): boolean { + return this._configurationService.getValue(SESSIONS_LIST_ENABLED_SETTING) === true; + } + + /** Whether the session is to start without a working directory. */ + isNoWorkspaceSelected(): boolean { + const sessionResource = this._sessionResource(); + return !!sessionResource && this._newSessionFolderService.isNoWorkspace(sessionResource); + } + + /** The chip while the session is to start without a working directory: by its icon and name. */ + getNoWorkspaceChip(): { readonly icon: ThemeIcon; readonly label: string; readonly description: string } { + return { icon: NO_WORKSPACE_ICON, label: getNoWorkspaceLabel(), description: localize('agentHost.folderPicker.noWorkspaceChipDescription', "No workspace: start the session in a temporary directory") }; + } + /** Whether the session is to work in a new worktree of its folder. */ isNewWorktreeSelected(): boolean { const isolation = this._isolation(); @@ -129,6 +153,9 @@ export class AgentHostWorktreeFolderChoices extends Disposable { const choices = this._choices(); const choice = choices.find(candidate => extUriBiasedIgnorePathCase.isEqual(candidate.uri, folder)); const primary = choices.find(candidate => candidate.kind === 'folder'); + if (this.isNoWorkspaceSelected()) { + return this.getNoWorkspaceChip(); + } if (this.isNewWorktreeSelected()) { return { icon: WORKTREE_ICON, label: getNewWorktreeLabel(), description: localize('agentHost.folderPicker.newWorktreeChipDescription', "New worktree of {0}", primary?.label ?? basename(folder)) }; } @@ -142,7 +169,8 @@ export class AgentHostWorktreeFolderChoices extends Disposable { /** The chip's choices, `selected` checked, unless the session is to work in a new worktree. */ getActions(action: MenuItemAction, selected: URI | undefined, onDidPick: () => void): IActionWidgetDropdownAction[] { - const newWorktreeSelected = this.isNewWorktreeSelected(); + const noWorkspaceSelected = this.isNoWorkspaceSelected(); + const newWorktreeSelected = !noWorkspaceSelected && this.isNewWorktreeSelected(); const actions: IActionWidgetDropdownAction[] = []; if (this._isolation()?.worktree) { actions.push({ @@ -168,7 +196,7 @@ export class AgentHostWorktreeFolderChoices extends Disposable { id: `agentHostFolder.${choice.uri.toString()}`, label: choice.label, description: choice.current ? getCurrentFolderLocationDescription() : undefined, - checked: !newWorktreeSelected && !!selected && extUriBiasedIgnorePathCase.isEqual(selected, choice.uri), + checked: !noWorkspaceSelected && !newWorktreeSelected && !!selected && extUriBiasedIgnorePathCase.isEqual(selected, choice.uri), icon: choice.kind === 'worktree' ? WORKTREE_ICON : Codicon.folder, enabled: true, tooltip: choice.uri.fsPath, @@ -186,6 +214,28 @@ export class AgentHostWorktreeFolderChoices extends Disposable { }); } }); + if (this.isNoWorkspaceAvailable() || noWorkspaceSelected) { + actions.push({ + ...action, + id: 'agentHostFolder.noWorkspace', + label: getNoWorkspaceLabel(), + tooltip: localize('agentHost.folderPicker.noWorkspaceDescription', "Start without a backing workspace"), + checked: noWorkspaceSelected, + icon: NO_WORKSPACE_ICON, + enabled: true, + category: { label: '', order: sections.length + 1 }, + run: async () => { + const sessionResource = this._sessionResource(); + if (!sessionResource) { + return; + } + // Not in a new worktree of a folder it no longer has + await this._setIsolation(false); + this._newSessionFolderService.setNoWorkspace(sessionResource); + onDidPick(); + }, + }); + } return actions; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostSessionListStoreProjectRoot.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostSessionListStoreProjectRoot.test.ts index d91c864cccb2d0..0f7521a4d64f8d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostSessionListStoreProjectRoot.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostSessionListStoreProjectRoot.test.ts @@ -13,7 +13,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../ba import { IAgentHostConnectionsService } from '../../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { identityAgentHostResourceUriMapper } from '../../../../../../../platform/agentHost/common/agentHostUri.js'; import { IAgentSessionMetadata } from '../../../../../../../platform/agentHost/common/agentService.js'; -import { withSessionMultiRootMetadata } from '../../../../../../../platform/agentHost/common/state/sessionState.js'; +import { withSessionMultiRootMetadata, withSessionWorkspaceless } from '../../../../../../../platform/agentHost/common/state/sessionState.js'; import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../../platform/log/common/log.js'; import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../../platform/workspace/common/workspace.js'; @@ -88,6 +88,32 @@ suite('AgentHostSessionListStore - project root', () => { }); }); + test('while matching by project root, also lists the sessions without a workspace, such as quick chats, but not of a multi-root workspace', async () => { + const workspaceless = (id: string, workspaceFile?: string): IAgentSessionMetadata => ({ + session: URI.from({ scheme: 'copilot', path: `/${id}` }), + startTime: 0, + modifiedTime: 0, + // Its scratch folder, which the agent host creates for it + workingDirectories: [URI.file(`/home/.copilot/chats/${id}`)], + _meta: withSessionWorkspaceless(workspaceFile ? withSessionMultiRootMetadata(undefined, { workspaceFile: URI.file(workspaceFile).toString() }) : undefined, true), + }); + const { matchProjectRoot, shown, settle } = createList([ + session('in-folder', '/work/vscode/src', '/work/vscode'), + workspaceless('quick-chat'), + workspaceless('multi-root-chat', '/work/other.code-workspace'), + ]); + + await settle(); + const off = shown(); + matchProjectRoot.set(true, undefined); + await settle(); + + assert.deepStrictEqual({ off, on: shown() }, { + off: ['in-folder'], + on: ['in-folder', 'quick-chat'], + }); + }); + test('in a window of a workspace file, a session of another workspace file whose project is one of its folders is not listed by its project', async () => { const { matchProjectRoot, shown, settle } = createList([ session('this-workspace', '/work/vscode.worktrees/a', '/work/vscode', '/work/this.code-workspace'), diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts index 78a037e84278c6..726950d21d0ef2 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts @@ -32,15 +32,16 @@ suite('AgentHostWorktreeFolderChoices', () => { const session = URI.from({ scheme: SessionType.AgentHostCopilot, path: '/untitled-1' }); /** The choices in a window whose folder is at `at`, recording the folders and isolation it sets for the session. */ - async function createChoices(options: { readonly enabled: boolean; readonly at: URI; readonly isolation?: string; readonly state?: WorkbenchState; readonly listed?: readonly URI[]; readonly resolveLocation?: (location: URI) => Promise }) { + async function createChoices(options: { readonly enabled: boolean; readonly at: URI; readonly isolation?: string; readonly state?: WorkbenchState; readonly listed?: readonly URI[]; readonly sessionsList?: boolean; readonly resolveLocation?: (location: URI) => Promise }) { const config = { values: { isolation: options.isolation ?? 'folder' } as Record }; const changes: string[] = []; const listed = { locations: options.listed ?? [feature, other] }; const onDidChangeSessions = store.add(new Emitter()); /** Changing the session's folder resets its configuration until it applies a change, as the provisional service does. */ let configReset = false; + let noWorkspace = false; const instantiationService = store.add(new TestInstantiationService()); - instantiationService.stub(IConfigurationService, new TestConfigurationService({ [ChatConfiguration.EditorWindowEnableWorktree]: options.enabled })); + instantiationService.stub(IConfigurationService, new TestConfigurationService({ [ChatConfiguration.EditorWindowEnableWorktree]: options.enabled, 'chat.viewSessions.sessionsList.enabled': options.sessionsList ?? false })); instantiationService.stub(IWorkspaceContextService, upcastPartial({ getWorkspace: () => upcastPartial({ uri: primary, folders: [upcastPartial({ uri: options.at })] }), getWorkbenchState: () => options.state ?? WorkbenchState.FOLDER, @@ -56,7 +57,9 @@ suite('AgentHostWorktreeFolderChoices', () => { })); instantiationService.stub(IAgentHostNewSessionFolderService, upcastPartial({ getFolder: () => undefined, - setFolder: (_resource: URI, folder: URI) => { changes.push(`folder ${folder.path}`); configReset = true; }, + setFolder: (_resource: URI, folder: URI) => { changes.push(`folder ${folder.path}`); configReset = true; noWorkspace = false; }, + isNoWorkspace: () => noWorkspace, + setNoWorkspace: () => { changes.push('no workspace'); configReset = true; noWorkspace = true; }, })); instantiationService.stub(IAgentHostUntitledProvisionalSessionService, upcastPartial({ onDidChange: Event.None, @@ -122,6 +125,30 @@ suite('AgentHostWorktreeFolderChoices', () => { }); }); + test('offers No Workspace last while the Chat view lists sessions, which the chip then shows by name, and picking a folder again works in it', async () => { + const withoutSessionsList = await createChoices({ enabled: true, at: primary }); + const { choices, actions, items, changes } = await createChoices({ enabled: true, at: primary, sessionsList: true }); + const chip = () => { const { icon, label, description } = choices.getChip(primary); return { icon: icon.id, label: label ?? null, description }; }; + await actions(primary).find(action => action.label === 'New Worktree')!.run(); + await actions(primary).find(action => action.label === 'No Workspace')!.run(); + const noWorkspace = { items: items(primary), chip: chip(), icon: actions(primary).at(-1)?.icon?.id, separate: actions(primary).at(-1)?.category?.label === '' }; + await actions(primary).find(action => action.label === 'vscode')!.run(); + + assert.deepStrictEqual({ withoutSessionsList: withoutSessionsList.items(primary), noWorkspace, folderAgain: { items: items(primary), chip: chip() }, changes }, { + withoutSessionsList: ['New Worktree', '[Folder] vscode (Current) *', '[Worktrees] feature', '[Worktrees] other'], + noWorkspace: { + // Without a folder, it has no worktree isolation until a folder's configuration resolves + items: ['[Folder] vscode (Current)', '[Worktrees] feature', '[Worktrees] other', 'No Workspace *'], + chip: { icon: 'comment', label: 'No Workspace', description: 'No workspace: start the session in a temporary directory' }, + icon: 'comment', + separate: true, + }, + folderAgain: { items: ['[Folder] vscode (Current) *', '[Worktrees] feature', '[Worktrees] other', 'No Workspace'], chip: { icon: 'folder', label: null, description: 'Folder: vscode' } }, + // No longer in a new worktree of the folder it no longer has + changes: ['isolation worktree', 'isolation folder', 'no workspace', 'folder /work/vscode'], + }); + }); + test('a folder it does not list, which a failed read does not prove a worktree, is read again as it reads the worktrees again, and shows as a worktree once proven', async () => { const unlisted = URI.file('/work/checkouts/fix'); let reads = 0; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 9a1293272bacb8..9125a8218d4eb7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -7793,6 +7793,36 @@ suite('AgentHostChatContribution', () => { }); })); + test('newChatSessionItem carries No Workspace forward to the session, also under the retained backend identity, rather than a folder', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { instantiationService, agentHostService, newSessionFolderService } = createTestServices(disposables, undefined, undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, [URI.file('/workspace')]); + const untitledResource = URI.parse('agent-host-copilot:/untitled-no-workspace'); + newSessionFolderService.setNoWorkspace(untitledResource); + let allocatedResource: URI | undefined; + instantiationService.stub(IAgentHostUntitledProvisionalSessionService, { + tryRebind: async (_oldResource: URI, newResource: URI) => { + allocatedResource = newResource; + return URI.parse('ahp-session:/retained-no-workspace'); + }, + getProvisionalWorkingDirectories: () => undefined, + } as Partial as IAgentHostUntitledProvisionalSessionService); + const listController = createSessionListController(disposables, instantiationService, agentHostService); + + const item = await listController.newChatSessionItem({ prompt: 'Hello', untitledResource }, CancellationToken.None); + assert.ok(item && allocatedResource); + + assert.deepStrictEqual({ + resource: item.resource.toString(), + noWorkspace: newSessionFolderService.isNoWorkspace(item.resource), + folder: newSessionFolderService.getFolder(item.resource), + oldAllocationNoWorkspace: newSessionFolderService.isNoWorkspace(allocatedResource), + }, { + resource: 'agent-host-copilot:/retained-no-workspace', + noWorkspace: true, + folder: undefined, + oldAllocationNoWorkspace: false, + }); + })); + test('newChatSessionItem skips rebind when no untitled provisional resource is provided', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { instantiationService, agentHostService } = createTestServices(disposables); @@ -19512,6 +19542,30 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.createSessionCalls[0].workingDirectories?.[0]?.toString(), URI.file('/custom/working/dir').toString()); })); + test('handler creates a session the user chose No Workspace for without a working directory, and others in the window\'s folder', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = URI.file('/workspace'); + const { instantiationService, agentHostService, chatAgentService, newSessionFolderService } = createTestServices(disposables, undefined, undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, [folder]); + const handler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { + provider: 'copilot' as const, + agentId: 'no-workspace-test', + sessionType: 'no-workspace-test', + fullName: 'Test', + description: 'test', + connection: agentHostService, + connectionAuthority: 'local', + })); + const noWorkspace = URI.from({ scheme: 'no-workspace-test', path: '/new-no-workspace' }); + newSessionFolderService.setNoWorkspace(noWorkspace); + + for (const sessionResource of [noWorkspace, URI.from({ scheme: 'no-workspace-test', path: '/new-in-folder' })]) { + const { turnPromise, session, turnId, fire } = await startTurn(handler, agentHostService, chatAgentService, disposables, { agentId: 'no-workspace-test', sessionResource }); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); + await turnPromise; + } + + assert.deepStrictEqual(agentHostService.createSessionCalls.map(call => call.workingDirectories?.map(directory => directory.toString())), [undefined, [folder.toString()]]); + })); + test('handler forwards request session config to createSession', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { instantiationService, agentHostService, chatAgentService } = createTestServices( disposables, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts index 24ddbdf4ebb0f6..c14203bcc03a7c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts @@ -297,7 +297,7 @@ suite('AgentHostChatInputPicker - combined mode and permissions', () => { instantiationService.stub(IAgentHostSessionWorkingDirectoryResolver, { resolve: () => undefined }); instantiationService.stub(IWorkspaceContextService, { getWorkspace: () => (testIWorkspace({ id: 'test', folders: [] })) }); instantiationService.set(IWorkspaceTrustManagementService, store.add(new TestWorkspaceTrustManagementService())); - instantiationService.stub(IAgentHostNewSessionFolderService, { getFolder: () => undefined, getDefaultFolder: () => undefined }); + instantiationService.stub(IAgentHostNewSessionFolderService, { getFolder: () => undefined, getDefaultFolder: () => undefined, isNoWorkspace: () => false }); const resolvedRefreshes: Record[] = []; instantiationService.stub(IAgentHostUntitledProvisionalSessionService, { onDidChange: Event.None, get: () => undefined, getResolvedConfig: () => undefined, refreshResolvedConfig: async (_resource, _provider, _directory, values) => { resolvedRefreshes.push(values ?? {}); } }); const managedSandboxEnforced = observableValue('managedSandboxEnforced', false); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostGenericConfigChips.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostGenericConfigChips.test.ts index 9b54b5904bde43..6a80287259b79c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostGenericConfigChips.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostGenericConfigChips.test.ts @@ -139,7 +139,7 @@ suite('AgentHostGenericConfigChips', () => { getOrCreate: async (...args) => { provisionalRequests.push(args); return undefined; }, }); instantiation.stub(IAgentHostSessionWorkingDirectoryResolver, { resolve: () => URI.file('/workspace') }); - instantiation.stub(IAgentHostNewSessionFolderService, { getFolder: () => folder, onDidChangeFolder: folderChanged.event }); + instantiation.stub(IAgentHostNewSessionFolderService, { getFolder: () => folder, isNoWorkspace: () => false, onDidChangeFolder: folderChanged.event }); instantiation.stub(IWorkspaceContextService, { onDidChangeWorkspaceFolders: Event.None }); instantiation.set(IWorkspaceTrustManagementService, trustService ?? disposables.add(new TestWorkspaceTrustManagementService(true, new ResourceSet([URI.file('/workspace')])))); disposables.add(configuration.onDidChangeConfigurationEmitter); @@ -951,7 +951,7 @@ suite('AgentHostGenericConfigChips - remote sessions', () => { instantiationService.stub(IAgentHostSessionWorkingDirectoryResolver, { resolve: () => undefined }); instantiationService.stub(IWorkspaceContextService, { getWorkspace: () => (testIWorkspace({ id: 'test', folders: [] })) }); instantiationService.set(IWorkspaceTrustManagementService, store.add(new TestWorkspaceTrustManagementService())); - instantiationService.stub(IAgentHostNewSessionFolderService, { getFolder: () => undefined, getDefaultFolder: () => undefined }); + instantiationService.stub(IAgentHostNewSessionFolderService, { getFolder: () => undefined, getDefaultFolder: () => undefined, isNoWorkspace: () => false }); instantiationService.stub(IAgentHostUntitledProvisionalSessionService, { onDidChange: Event.None, get: () => undefined, getResolvedConfig: () => undefined, refreshResolvedConfig: async (...args) => { refreshes.push(args); }, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostNewSessionFolderService.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostNewSessionFolderService.test.ts index 9c3fb49641b323..4ad651c4339bda 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostNewSessionFolderService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostNewSessionFolderService.test.ts @@ -76,6 +76,27 @@ suite('AgentHostNewSessionFolderService', () => { }); }); + test('No Workspace replaces a session\'s folder without becoming the window\'s default, and a folder picked again replaces it', () => { + const changes: string[] = []; + cleanup.add(service.onDidChangeFolder(uri => changes.push(uri.path))); + + service.setFolder(sessionA, folderB); + service.setNoWorkspace(sessionA); + service.setNoWorkspace(sessionA); // same value: no event + const noWorkspace = { folder: service.getFolder(sessionA), isNoWorkspace: service.isNoWorkspace(sessionA), primary: service.resolveNewSessionPrimary(sessionA), otherSession: service.isNoWorkspace(sessionB), defaultFolder: service.getDefaultFolder()?.path }; + service.setFolder(sessionA, folderB); + const folderAgain = { folder: service.getFolder(sessionA)?.path, isNoWorkspace: service.isNoWorkspace(sessionA) }; + service.setNoWorkspace(sessionA); + service.clear(sessionA); + + assert.deepStrictEqual({ noWorkspace, folderAgain, cleared: service.isNoWorkspace(sessionA), changes }, { + noWorkspace: { folder: undefined, isNoWorkspace: true, primary: undefined, otherSession: false, defaultFolder: '/repoB' }, + folderAgain: { folder: '/repoB', isNoWorkspace: false }, + cleared: false, + changes: ['/untitled-a', '/untitled-a', '/untitled-a', '/untitled-a', '/untitled-a'], + }); + }); + test('clears the chosen folder when its session is disposed', () => { const changes: string[] = []; cleanup.add(service.onDidChangeFolder(uri => changes.push(uri.toString()))); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts index 151e74e280438c..993353ce714d3b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts @@ -645,6 +645,29 @@ suite('AgentHostUntitledProvisionalSessionService', () => { }); }); + test('No Workspace recreates the provisional without a working directory, and a folder picked again recreates it there', async () => { + const ui = untitledChatUri('no-workspace'); + const folder = URI.file('/folder'); + folderService.setFolder(ui, folder); + const initialBackend = await provisional.getOrCreate(ui, 'copilot', folder); + assert.ok(initialBackend); + folderService.setNoWorkspace(ui); + const workspaceless = await provisional.waitForPending(ui); + const noWorkspaceDirectories = workspaceless && provisional.getProvisionalWorkingDirectories(ui); + folderService.setFolder(ui, folder); + await provisional.waitForPending(ui); + + assert.deepStrictEqual({ + created: agentHost.createCalls.map(call => call.workingDirectories?.map(uri => uri.toString())), + noWorkspaceDirectories, + disposed: agentHost.disposed.length, + }, { + created: [[folder.toString()], undefined, [folder.toString()]], + noWorkspaceDirectories: undefined, + disposed: 2, + }); + }); + test('disposing either UI mapping preserves the graduated backend', async () => { const ui = untitledChatUri('retained-backend-disposal'); const real = ui.with({ path: '/real-retained-backend-disposal' }); From 8f1dd928c048d0b041a0576aecfd9c3c011af0a3 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 19:05:30 +0100 Subject: [PATCH 135/165] Worktree support: the Chat view's folder chip always shows the folder or worktree by its name, also while compact Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostFolderPickerActionItem.ts | 4 +-- .../agentHostWorktreeFolderChoices.ts | 10 ++++---- .../agentHostWorktreeFolderChoices.test.ts | 25 +++++++++++-------- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts index c8abd0ecfc6b8a..082161b4bc97b1 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts @@ -172,12 +172,12 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt protected override renderLabel(element: HTMLElement): IDisposable | null { const selected = this._selectedFolder(); const folder = selected && this._workspaceContextService.getWorkspace().folders.find(f => f.uri.toString() === selected.toString()); - // With worktree support, by its icon only, a folder or a worktree, but New Worktree and No Workspace by their names + // With worktree support, by its icon and name, a folder, a worktree, New Worktree or No Workspace, also while compact const chip = this._worktree?.isActive() && this._worktree.isNoWorkspaceSelected() ? this._worktree.getNoWorkspaceChip() : selected && this._worktree?.isActive() ? this._worktree.getChip(selected) : undefined; const label = chip ? chip.description : folder ? folder.name : (selected ? basename(selected) : localize('agentHost.selectFolder', "Folder")); const shownLabel = chip ? chip.label : label; - const compact = this.pickerOptions.compact.get(); + const compact = !chip && this.pickerOptions.compact.get(); element.classList.toggle('icon-only', compact || !shownLabel); dom.reset( element, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts index d2d6bc3826fd40..1bf2b17857600e 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts @@ -146,10 +146,10 @@ export class AgentHostWorktreeFolderChoices extends Disposable { } /** - * The chip for the selected `folder`: by its icon only, a folder or a worktree, but New Worktree by its name, with what it - * is for screen readers and on hover. + * The chip for the selected `folder`: by its icon, a folder or a worktree, and its name, or New Worktree by that name, with + * what it is for screen readers and on hover. The name shows at once; only the icon settles once git proves a worktree. */ - getChip(folder: URI): { readonly icon: ThemeIcon; readonly label: string | undefined; readonly description: string } { + getChip(folder: URI): { readonly icon: ThemeIcon; readonly label: string; readonly description: string } { const choices = this._choices(); const choice = choices.find(candidate => extUriBiasedIgnorePathCase.isEqual(candidate.uri, folder)); const primary = choices.find(candidate => candidate.kind === 'folder'); @@ -163,8 +163,8 @@ export class AgentHostWorktreeFolderChoices extends Disposable { // A folder it does not list, such as the worktree a started session works in, is a worktree once git proves it one const worktree = choice ? choice.kind === 'worktree' : this._isProvenWorktree(folder); return worktree - ? { icon: WORKTREE_ICON, label: undefined, description: localize('agentHost.folderPicker.worktreeChipDescription', "Worktree: {0}", name) } - : { icon: Codicon.folder, label: undefined, description: localize('agentHost.folderPicker.folderChipDescription', "Folder: {0}", name) }; + ? { icon: WORKTREE_ICON, label: name, description: localize('agentHost.folderPicker.worktreeChipDescription', "Worktree: {0}", name) } + : { icon: Codicon.folder, label: name, description: localize('agentHost.folderPicker.folderChipDescription', "Folder: {0}", name) }; } /** The chip's choices, `selected` checked, unless the session is to work in a new worktree. */ diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts index 726950d21d0ef2..ec2da1e1d0084d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts @@ -97,13 +97,13 @@ suite('AgentHostWorktreeFolderChoices', () => { test('New Worktree has the session work in a new worktree, which the label shows, and picking a folder again works in it', async () => { const { choices, actions, items, changes } = await createChoices({ enabled: true, at: primary }); - const chip = (folder: URI) => { const { icon, label, description } = choices.getChip(folder); return { icon: icon.id, label: label ?? null, description }; }; + const chip = (folder: URI) => { const { icon, label, description } = choices.getChip(folder); return { icon: icon.id, label, description }; }; const folderChip = chip(primary); await actions(primary).find(action => action.label === 'New Worktree')!.run(); const inNewWorktree = { items: items(primary), chip: chip(primary) }; await actions(primary).find(action => action.label === 'feature')!.run(); - // By its icon only, but New Worktree by its name; what it is, by name, for screen readers and on hover + // By its icon and name, or New Worktree by that name; what it is, by name, for screen readers and on hover // An existing chat, whose started session works in a folder that the chip does not list: a worktree once git proves it // one; another repository, or the window's folder by another path, is a folder // A folder named like a worktree VS Code creates for the folder shows as one before it is read, as after a reload; one elsewhere once git proves it @@ -115,20 +115,20 @@ suite('AgentHostWorktreeFolderChoices', () => { assert.deepStrictEqual({ inNewWorktree, changes, folderChip, worktreeChip: chip(feature), beforeRead, unlistedWorktreeChip: chip(unlisted.worktree), elsewhereChip: chip(unlisted.elsewhere), otherRepositoryChip: chip(unlisted.otherRepository), primaryAliasChip: chip(unlisted.primaryAlias) }, { inNewWorktree: { items: ['New Worktree *', '[Folder] vscode (Current)', '[Worktrees] feature', '[Worktrees] other'], chip: { icon: 'worktree', label: 'New Worktree', description: 'New worktree of vscode' } }, changes: ['isolation worktree', 'folder /work/vscode.worktrees/feature', 'isolation folder'], - folderChip: { icon: 'folder', label: null, description: 'Folder: vscode' }, - worktreeChip: { icon: 'worktree', label: null, description: 'Worktree: feature' }, + folderChip: { icon: 'folder', label: 'vscode', description: 'Folder: vscode' }, + worktreeChip: { icon: 'worktree', label: 'feature', description: 'Worktree: feature' }, beforeRead: { worktree: 'worktree', elsewhere: 'folder' }, - unlistedWorktreeChip: { icon: 'worktree', label: null, description: 'Worktree: small-random-change-addition' }, - elsewhereChip: { icon: 'worktree', label: null, description: 'Worktree: fix' }, - otherRepositoryChip: { icon: 'folder', label: null, description: 'Folder: another-repo' }, - primaryAliasChip: { icon: 'folder', label: null, description: 'Folder: vscode' }, + unlistedWorktreeChip: { icon: 'worktree', label: 'small-random-change-addition', description: 'Worktree: small-random-change-addition' }, + elsewhereChip: { icon: 'worktree', label: 'fix', description: 'Worktree: fix' }, + otherRepositoryChip: { icon: 'folder', label: 'another-repo', description: 'Folder: another-repo' }, + primaryAliasChip: { icon: 'folder', label: 'vscode', description: 'Folder: vscode' }, }); }); test('offers No Workspace last while the Chat view lists sessions, which the chip then shows by name, and picking a folder again works in it', async () => { const withoutSessionsList = await createChoices({ enabled: true, at: primary }); const { choices, actions, items, changes } = await createChoices({ enabled: true, at: primary, sessionsList: true }); - const chip = () => { const { icon, label, description } = choices.getChip(primary); return { icon: icon.id, label: label ?? null, description }; }; + const chip = () => { const { icon, label, description } = choices.getChip(primary); return { icon: icon.id, label, description }; }; await actions(primary).find(action => action.label === 'New Worktree')!.run(); await actions(primary).find(action => action.label === 'No Workspace')!.run(); const noWorkspace = { items: items(primary), chip: chip(), icon: actions(primary).at(-1)?.icon?.id, separate: actions(primary).at(-1)?.category?.label === '' }; @@ -143,7 +143,7 @@ suite('AgentHostWorktreeFolderChoices', () => { icon: 'comment', separate: true, }, - folderAgain: { items: ['[Folder] vscode (Current) *', '[Worktrees] feature', '[Worktrees] other', 'No Workspace'], chip: { icon: 'folder', label: null, description: 'Folder: vscode' } }, + folderAgain: { items: ['[Folder] vscode (Current) *', '[Worktrees] feature', '[Worktrees] other', 'No Workspace'], chip: { icon: 'folder', label: 'vscode', description: 'Folder: vscode' } }, // No longer in a new worktree of the folder it no longer has changes: ['isolation worktree', 'isolation folder', 'no workspace', 'folder /work/vscode'], }); @@ -192,6 +192,8 @@ suite('AgentHostWorktreeFolderChoices', () => { store.add(choices.onDidChange(() => changes++)); const settle = () => new Promise(resolve => setTimeout(resolve, 0)); const icons = () => [real, notWorktree, primaryLink, flaky].map(folder => choices.getChip(folder).icon.id); + // Its name shows at once, before git answers + const labels = [real, notWorktree, primaryLink, flaky].map(folder => choices.getChip(folder).label); const pending = icons(); await settle(); const settled = { icons: icons(), changes, description: choices.getChip(notWorktree).description }; @@ -200,7 +202,8 @@ suite('AgentHostWorktreeFolderChoices', () => { const pendingAgain = icons(); await settle(); - assert.deepStrictEqual({ pending, settled, pendingAgain, reread: icons() }, { + assert.deepStrictEqual({ labels, pending, settled, pendingAgain, reread: icons() }, { + labels: ['real', 'not-a-worktree', 'primary-link', 'flaky'], pending: ['worktree', 'worktree', 'worktree', 'worktree'], settled: { icons: ['worktree', 'folder', 'folder', 'folder'], changes: 3, description: 'Folder: not-a-worktree' }, pendingAgain: ['worktree', 'worktree', 'worktree', 'worktree'], From 8dba43d6817fb41278f4ef161c8a79ab9163cedc Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 19:08:48 +0100 Subject: [PATCH 136/165] Sessions: an editor window's composer offers No Workspace beside its folders, by that name Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/sessionWorkspacePicker.test.ts | 22 +++++++++++-------- .../sessions/browser/chat/newChatWidget.ts | 4 ++-- .../browser/chat/sessionWorkspacePicker.ts | 20 ++++++++++++----- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index 7e923d6136ff0f..a62152f6720816 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -8872,7 +8872,7 @@ suite('WorkspacePicker - Allowed folders', () => { }); }); - test('offers No Workspace as Chat before the window\'s folders, also beside its one folder, and names the current choice', () => { + test('offers No Workspace by that name before the window\'s folders, also beside its one folder, and names the current choice by what it is', () => { const noWorkspace = { selected: false }; const { picker, allowedFolders, snapshot } = createPicker(['/local/vscode'], noWorkspace); picker.setSelectedWorkspace(URI.file('/local/vscode'), { fireEvent: false }); @@ -8882,14 +8882,18 @@ suite('WorkspacePicker - Allowed folders', () => { const trigger = slot.querySelector('[role="button"]')!; const triggerState = () => ({ hidden: slot.hidden, readOnly: trigger.getAttribute('aria-disabled'), popup: trigger.getAttribute('aria-haspopup'), ariaLabel: trigger.getAttribute('aria-label') }); const oneFolder = { items: snapshot().items, trigger: triggerState() }; - picker.getItems().find(item => item.label === 'Chat')!.item!.run!(); - const oneFolderChat = triggerState(); + picker.getItems().find(item => item.label === 'No Workspace')!.item!.run!(); + const oneFolderNoWorkspace = { trigger: triggerState(), label: trigger.querySelector('.sessions-chat-dropdown-label')?.textContent, hover: picker.getSelectedLocationDescription() }; allowedFolders.set([URI.file('/local/vscode'), URI.file('/local/tools')], undefined); - assert.deepStrictEqual({ oneFolder, oneFolderChat, multiRoot: snapshot().items }, { - oneFolder: { items: ['Chat', '', '/local/vscode'], trigger: { hidden: false, readOnly: null, popup: 'listbox', ariaLabel: 'Workspace: local/vscode' } }, - oneFolderChat: { hidden: false, readOnly: null, popup: 'listbox', ariaLabel: 'Workspace: Chat' }, - multiRoot: ['Chat', '', '/local/vscode', '/local/tools'], + assert.deepStrictEqual({ oneFolder, oneFolderNoWorkspace, multiRoot: snapshot().items }, { + oneFolder: { items: ['No Workspace', '', '/local/vscode'], trigger: { hidden: false, readOnly: null, popup: 'listbox', ariaLabel: 'Workspace: local/vscode' } }, + oneFolderNoWorkspace: { + trigger: { hidden: false, readOnly: null, popup: 'listbox', ariaLabel: 'No workspace: start the session in a temporary directory' }, + label: 'No Workspace', + hover: 'No workspace: start the session in a temporary directory', + }, + multiRoot: ['No Workspace', '', '/local/vscode', '/local/tools'], }); }); @@ -8942,10 +8946,10 @@ suite('WorkspacePicker - Allowed folders', () => { const atFolder = items(); assert.deepStrictEqual({ atWorktree, ariaLabels, atFolder }, { - atWorktree: ['Chat', '---', 'New Worktree', '--- Folder', 'local/vscode', '--- Worktrees', 'b (Current) *', 'a (feature-a)'], + atWorktree: ['No Workspace', '---', 'New Worktree', '--- Folder', 'local/vscode', '--- Worktrees', 'b (Current) *', 'a (feature-a)'], // Screen readers hear where the window's folder is now, apart from the checked choice ariaLabels: [null, 'b, Current', null], - atFolder: ['Chat', '---', 'New Worktree', '--- Folder', 'local/vscode (Current) *', '--- Worktrees', 'b (feature-b)', 'a (feature-a)'], + atFolder: ['No Workspace', '---', 'New Worktree', '--- Folder', 'local/vscode (Current) *', '--- Worktrees', 'b (feature-b)', 'a (feature-a)'], }); }); diff --git a/src/vs/workbench/contrib/sessions/browser/chat/newChatWidget.ts b/src/vs/workbench/contrib/sessions/browser/chat/newChatWidget.ts index 61db558134aa6e..64c25fbe5a77af 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/newChatWidget.ts +++ b/src/vs/workbench/contrib/sessions/browser/chat/newChatWidget.ts @@ -442,8 +442,8 @@ export class NewChatWidget extends Disposable { } return undefined; }, - // A composer restricted to its folders, such as an editor window's, always creates its sessions in one - getNoWorkspaceOption: () => this._draftHost?.allowedFolders?.get().length ? undefined : this._getNoWorkspaceOption(), + // A composer restricted to its folders, such as an editor window's, also offers No Workspace beside them + getNoWorkspaceOption: () => this._getNoWorkspaceOption(), ownNoWorkspaceSelection: !!this._draftHost, allowedFolders: this._draftHost?.allowedFolders, getAllowedFolderPresentation: this._draftHost?.getFolderPresentation && (folderUri => this._draftHost?.getFolderPresentation?.(folderUri)), diff --git a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts index 486d35d2954cce..6418010862d772 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts +++ b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts @@ -2388,7 +2388,13 @@ export class WorkspacePicker extends Disposable { } protected _getNoWorkspaceLabel(): string { - return getNoWorkspaceChoicePresentation(undefined).label; + // Beside a window's folders, by what it is rather than as a chat + return this._getAllowedFolders() ? localize('workspacePicker.restrictedNoWorkspace', "No Workspace") : getNoWorkspaceChoicePresentation(undefined).label; + } + + /** What No Workspace is, beside a window's folders: for screen readers and on hover. */ + private _getRestrictedNoWorkspaceDescription(): string | undefined { + return this._getAllowedFolders() ? localize('workspacePicker.restrictedNoWorkspaceDescription', "No workspace: start the session in a temporary directory") : undefined; } private _getSelectedNoWorkspaceLabel(): string { @@ -2504,9 +2510,10 @@ export class WorkspacePicker extends Disposable { : label && label !== emptyLabel ? localize('workspacePicker.categorySelectionAriaLabel', "{0}: {1}", options.label ?? options.ariaLabel, label) : options.ariaLabel); - if (chip && badgeCount === 0) { - // What it is, a folder, a worktree or New Worktree, by name for screen readers - trigger.setAttribute('aria-label', chip.description); + // What it is, a folder, a worktree, New Worktree or No Workspace, by name for screen readers + const chipDescription = chip?.description ?? (noWorkspaceSelected ? this._getRestrictedNoWorkspaceDescription() : undefined); + if (chipDescription && badgeCount === 0) { + trigger.setAttribute('aria-label', chipDescription); } if (reflectsWorkspace) { this._setTriggerReadOnly(trigger, readOnly); @@ -2984,7 +2991,10 @@ export class WorkspacePicker extends Disposable { /** What the chip of a picker restricted to a window's folder and its worktrees shows, by name: for its hover. */ getSelectedLocationDescription(): string | undefined { - const selected = this._getNoWorkspaceOption()?.isSelected ? undefined : this._selectedResolved; + if (this._getNoWorkspaceOption()?.isSelected) { + return this._getRestrictedNoWorkspaceDescription(); + } + const selected = this._selectedResolved; return selected && this._getLocationChip(selected.workspace, selected.providerId)?.description; } From 8d58ac9bf21ee3f2981924610e591ae950e52d61 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 19:12:52 +0100 Subject: [PATCH 137/165] Worktree support: the Chat view's folder chip shows only for a new chat, rather than disabled in a started session Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostChatInputPicker.contribution.ts | 4 +++- .../agentHostChatContribution.test.ts | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts index 91e25f858135e5..8f73e2a4eabb6e 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts @@ -78,10 +78,12 @@ export class OpenAgentHostFolderPickerAction extends Action2 { group: 'navigation', // First, before the session type, as the new-session composer leads with its workspace order: -1, - // With worktree support, a window with a single local folder offers New Worktree, the folder and its worktrees + // With worktree support, a window with a single local folder offers New Worktree, the folder and its worktrees. + // Only for a new chat: a started session's folder is fixed, so it does not show it rather than disable it. when: ContextKeyExpr.and( ChatContextKeys.enabled, ChatContextKeyExprs.isAgentHostSession, + ChatContextKeys.chatSessionIsEmpty, ContextKeyExpr.equals(`config.${ChatConfiguration.EditorWindowEnableWorktree}`, true), WorkbenchStateContext.isEqualTo('folder'), RemoteNameContext.isEqualTo(''), diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 9125a8218d4eb7..45d3fdadafeb47 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -8031,6 +8031,25 @@ suite('AgentHostChatContribution', () => { }, { worktreeFirst: true, multiRootOrder: 1.1 }); }); + test('with worktree support, the folder picker shows only for a new chat, not for a started or restored session', () => { + const worktree = MenuRegistry.getMenuItems(MenuId.ChatInputSecondary) + .find((i): i is IMenuItem => isIMenuItem(i) && i.command.id === OpenAgentHostFolderPickerAction.ID && !!i.when?.keys().includes(`config.${ChatConfiguration.EditorWindowEnableWorktree}`)); + const evalWhen = (values: Record) => !!worktree?.when?.evaluate({ getValue: (key: string) => values[key] as T }); + const singleFolderWindow = { + [ChatContextKeys.enabled.key]: true, + [ChatContextKeys.chatIsAgentHostSession.key]: true, + [`config.${ChatConfiguration.EditorWindowEnableWorktree}`]: true, + workbenchState: 'folder', + remoteName: '', + isSessionsWindow: false, + }; + + assert.deepStrictEqual({ + newChat: evalWhen({ ...singleFolderWindow, [ChatContextKeys.chatSessionIsEmpty.key]: true }), + startedOrRestored: evalWhen({ ...singleFolderWindow, [ChatContextKeys.chatSessionIsEmpty.key]: false }), + }, { newChat: true, startedOrRestored: false }); + }); + test('folder picker is visible only in multi-root agent-host editor windows when the provider pins an immutable primary directory', () => { const item = MenuRegistry.getMenuItems(MenuId.ChatInputSecondary) .find((i): i is IMenuItem => isIMenuItem(i) && i.command.id === OpenAgentHostFolderPickerAction.ID); From a9ca279904b469f57de3fb4d0128010f4a7ff7b4 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 19:16:44 +0100 Subject: [PATCH 138/165] Revert "Worktree support: the Chat view's folder chip shows only for a new chat, rather than disabled in a started session" This reverts commit 8d58ac9bf21ee3f2981924610e591ae950e52d61. --- .../agentHostChatInputPicker.contribution.ts | 4 +--- .../agentHostChatContribution.test.ts | 19 ------------------- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts index 8f73e2a4eabb6e..91e25f858135e5 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts @@ -78,12 +78,10 @@ export class OpenAgentHostFolderPickerAction extends Action2 { group: 'navigation', // First, before the session type, as the new-session composer leads with its workspace order: -1, - // With worktree support, a window with a single local folder offers New Worktree, the folder and its worktrees. - // Only for a new chat: a started session's folder is fixed, so it does not show it rather than disable it. + // With worktree support, a window with a single local folder offers New Worktree, the folder and its worktrees when: ContextKeyExpr.and( ChatContextKeys.enabled, ChatContextKeyExprs.isAgentHostSession, - ChatContextKeys.chatSessionIsEmpty, ContextKeyExpr.equals(`config.${ChatConfiguration.EditorWindowEnableWorktree}`, true), WorkbenchStateContext.isEqualTo('folder'), RemoteNameContext.isEqualTo(''), diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 45d3fdadafeb47..9125a8218d4eb7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -8031,25 +8031,6 @@ suite('AgentHostChatContribution', () => { }, { worktreeFirst: true, multiRootOrder: 1.1 }); }); - test('with worktree support, the folder picker shows only for a new chat, not for a started or restored session', () => { - const worktree = MenuRegistry.getMenuItems(MenuId.ChatInputSecondary) - .find((i): i is IMenuItem => isIMenuItem(i) && i.command.id === OpenAgentHostFolderPickerAction.ID && !!i.when?.keys().includes(`config.${ChatConfiguration.EditorWindowEnableWorktree}`)); - const evalWhen = (values: Record) => !!worktree?.when?.evaluate({ getValue: (key: string) => values[key] as T }); - const singleFolderWindow = { - [ChatContextKeys.enabled.key]: true, - [ChatContextKeys.chatIsAgentHostSession.key]: true, - [`config.${ChatConfiguration.EditorWindowEnableWorktree}`]: true, - workbenchState: 'folder', - remoteName: '', - isSessionsWindow: false, - }; - - assert.deepStrictEqual({ - newChat: evalWhen({ ...singleFolderWindow, [ChatContextKeys.chatSessionIsEmpty.key]: true }), - startedOrRestored: evalWhen({ ...singleFolderWindow, [ChatContextKeys.chatSessionIsEmpty.key]: false }), - }, { newChat: true, startedOrRestored: false }); - }); - test('folder picker is visible only in multi-root agent-host editor windows when the provider pins an immutable primary directory', () => { const item = MenuRegistry.getMenuItems(MenuId.ChatInputSecondary) .find((i): i is IMenuItem => isIMenuItem(i) && i.command.id === OpenAgentHostFolderPickerAction.ID); From d7287d367253c69d7abf814f5e2a23a7890fb12a Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 19:16:49 +0100 Subject: [PATCH 139/165] Revert "Worktree support: the folder chip leads the chat input's pickers, as the new-session composer leads with its workspace" This reverts commit f9ca40aa1299691c59ad6f26badac73e83d2aedb. --- .../agentHostChatInputPicker.contribution.ts | 7 +------ .../agentSessions/agentHostChatContribution.test.ts | 13 ------------- 2 files changed, 1 insertion(+), 19 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts index 91e25f858135e5..b16bc4869cfb55 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts @@ -28,10 +28,6 @@ const modeAndPermissionsWhen = ContextKeyExpr.and( * menu by VS Code's menu/toolbar convention. * * Order layout in the secondary toolbar (existing items annotated): - * -1 OpenAgentHostFolderPickerAction (NEW — Folder, with worktree support - * in a single-folder window: first, - * as the new-session composer leads - * with its workspace) * 0 OpenSessionTargetPickerAction (Copilot CLI - Agent Host) * 0.5 OpenDelegationPickerAction * 0.7 OpenAgentHostModePickerAction (NEW — Mode) @@ -76,8 +72,7 @@ export class OpenAgentHostFolderPickerAction extends Action2 { }, { id: MenuId.ChatInputSecondary, group: 'navigation', - // First, before the session type, as the new-session composer leads with its workspace - order: -1, + order: 1.1, // With worktree support, a window with a single local folder offers New Worktree, the folder and its worktrees when: ContextKeyExpr.and( ChatContextKeys.enabled, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 9125a8218d4eb7..7d05a254c4537b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -8018,19 +8018,6 @@ suite('AgentHostChatContribution', () => { }, { carried: '/workspace/b', windowDefault: '/workspace/a' }); })); - test('with worktree support, the folder picker leads the chat input\'s pickers; without it, the multi-root folder picker stays last', () => { - const items = MenuRegistry.getMenuItems(MenuId.ChatInputSecondary).filter((i): i is IMenuItem => isIMenuItem(i) && i.group === 'navigation'); - const folderItems = items.filter(i => i.command.id === OpenAgentHostFolderPickerAction.ID); - const worktree = folderItems.find(i => !!i.when?.keys().includes(`config.${ChatConfiguration.EditorWindowEnableWorktree}`)); - const multiRoot = folderItems.find(i => i !== worktree); - const others = items.filter(i => i.command.id !== OpenAgentHostFolderPickerAction.ID).map(i => i.order ?? 0); - - assert.deepStrictEqual({ - worktreeFirst: (worktree?.order ?? 0) < Math.min(...others), - multiRootOrder: multiRoot?.order, - }, { worktreeFirst: true, multiRootOrder: 1.1 }); - }); - test('folder picker is visible only in multi-root agent-host editor windows when the provider pins an immutable primary directory', () => { const item = MenuRegistry.getMenuItems(MenuId.ChatInputSecondary) .find((i): i is IMenuItem => isIMenuItem(i) && i.command.id === OpenAgentHostFolderPickerAction.ID); From 66d0ee3d73f080cf38a5447effd7dda5e12fc7a8 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 19:17:47 +0100 Subject: [PATCH 140/165] Worktree support: test that the folder chip sits where the multi-root folder picker does and stays shown, disabled, once the session starts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostChatContribution.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 7d05a254c4537b..990a06e29b9022 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -8018,6 +8018,20 @@ suite('AgentHostChatContribution', () => { }, { carried: '/workspace/b', windowDefault: '/workspace/a' }); })); + test('with worktree support, the folder picker sits where the multi-root folder picker does, and stays shown, disabled, once the session starts', () => { + const folderItems = MenuRegistry.getMenuItems(MenuId.ChatInputSecondary) + .filter((i): i is IMenuItem => isIMenuItem(i) && i.group === 'navigation' && i.command.id === OpenAgentHostFolderPickerAction.ID); + const worktree = folderItems.find(i => !!i.when?.keys().includes(`config.${ChatConfiguration.EditorWindowEnableWorktree}`)); + const multiRoot = folderItems.find(i => i !== worktree); + + assert.deepStrictEqual({ + order: worktree?.order, + multiRootOrder: multiRoot?.order, + hiddenOnceStarted: !!worktree?.when?.keys().includes(ChatContextKeys.chatSessionIsEmpty.key), + disabledOnceStarted: !!worktree?.command.precondition?.keys().includes(ChatContextKeys.chatSessionIsEmpty.key), + }, { order: 1.1, multiRootOrder: 1.1, hiddenOnceStarted: false, disabledOnceStarted: true }); + }); + test('folder picker is visible only in multi-root agent-host editor windows when the provider pins an immutable primary directory', () => { const item = MenuRegistry.getMenuItems(MenuId.ChatInputSecondary) .find((i): i is IMenuItem => isIMenuItem(i) && i.command.id === OpenAgentHostFolderPickerAction.ID); From 7fd1f0a3e28fb10e950ea2b917b6eb42f1f1658f Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 19:24:35 +0100 Subject: [PATCH 141/165] Worktree support: No Workspace applies at once, so a send right after it starts the session without a folder Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostWorktreeFolderChoices.ts | 15 ++++++++--- .../agentHostWorktreeFolderChoices.test.ts | 27 ++++++++++++++++--- ...tUntitledProvisionalSessionService.test.ts | 22 +++++++++++++++ 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts index 1bf2b17857600e..bb60f799460ecb 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts @@ -172,7 +172,8 @@ export class AgentHostWorktreeFolderChoices extends Disposable { const noWorkspaceSelected = this.isNoWorkspaceSelected(); const newWorktreeSelected = !noWorkspaceSelected && this.isNewWorktreeSelected(); const actions: IActionWidgetDropdownAction[] = []; - if (this._isolation()?.worktree) { + // A new worktree is of the selected folder, which No Workspace does not have + if (!noWorkspaceSelected && this._isolation()?.worktree) { actions.push({ ...action, id: 'agentHostFolder.newWorktree', @@ -224,14 +225,20 @@ export class AgentHostWorktreeFolderChoices extends Disposable { icon: NO_WORKSPACE_ICON, enabled: true, category: { label: '', order: sections.length + 1 }, - run: async () => { + run: () => { const sessionResource = this._sessionResource(); + const provider = sessionResource && getLocalAgentHostSessionProvider(sessionResource); if (!sessionResource) { return; } - // Not in a new worktree of a folder it no longer has - await this._setIsolation(false); + // Read before the folder changes, which resets the session's configuration + const isolation = this._isolation(); + // All at once, as a send right after it graduates the session with what it then has: without a folder, + // and not in a new worktree of a folder it no longer has this._newSessionFolderService.setNoWorkspace(sessionResource); + if (provider && isolation?.inPlace && isolation.values[isolation.key] !== isolation.inPlace) { + this._provisional.applyConfigChange(sessionResource, provider, undefined, { [isolation.key]: isolation.inPlace }).catch(onUnexpectedError); + } onDidPick(); }, }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts index ec2da1e1d0084d..a5d9b6ed691a2f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts @@ -40,6 +40,8 @@ suite('AgentHostWorktreeFolderChoices', () => { /** Changing the session's folder resets its configuration until it applies a change, as the provisional service does. */ let configReset = false; let noWorkspace = false; + /** The working directory of each configuration change, `none` without one. */ + const configDirectories: string[] = []; const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(IConfigurationService, new TestConfigurationService({ [ChatConfiguration.EditorWindowEnableWorktree]: options.enabled, 'chat.viewSessions.sessionsList.enabled': options.sessionsList ?? false })); instantiationService.stub(IWorkspaceContextService, upcastPartial({ @@ -65,8 +67,9 @@ suite('AgentHostWorktreeFolderChoices', () => { onDidChange: Event.None, getResolvedConfig: () => configReset ? undefined : ({ schema: { type: 'object', properties: { isolation: { type: 'string', title: 'Isolation', enum: ['folder', 'worktree'] } } }, values: config.values }), getPreparedSessionConfig: () => undefined, - applyConfigChange: async (_resource: URI, _provider: string, _workingDirectory: URI | undefined, partial: Record) => { + applyConfigChange: async (_resource: URI, _provider: string, workingDirectory: URI | undefined, partial: Record) => { changes.push(`isolation ${partial.isolation}`); + configDirectories.push(workingDirectory?.path ?? 'none'); config.values = { ...config.values, ...partial }; configReset = false; return session; @@ -78,7 +81,7 @@ suite('AgentHostWorktreeFolderChoices', () => { await new Promise(resolve => setTimeout(resolve, 0)); const actions = (selected: URI) => choices.getActions(upcastPartial({}), selected, () => { }); const items = (selected: URI) => actions(selected).map(action => `${action.category?.label ? `[${action.category.label}] ` : ''}${action.label}${action.description ? ` (${action.description})` : ''}${action.checked ? ' *' : ''}`); - return { choices, actions, items, changes, listed, onDidChangeSessions }; + return { choices, actions, items, changes, configDirectories, listed, onDidChangeSessions }; } test('offers New Worktree first, then the folder and its worktrees, each section with where the folder is now first', async () => { @@ -137,7 +140,7 @@ suite('AgentHostWorktreeFolderChoices', () => { assert.deepStrictEqual({ withoutSessionsList: withoutSessionsList.items(primary), noWorkspace, folderAgain: { items: items(primary), chip: chip() }, changes }, { withoutSessionsList: ['New Worktree', '[Folder] vscode (Current) *', '[Worktrees] feature', '[Worktrees] other'], noWorkspace: { - // Without a folder, it has no worktree isolation until a folder's configuration resolves + // Without a folder, no new worktree of it items: ['[Folder] vscode (Current)', '[Worktrees] feature', '[Worktrees] other', 'No Workspace *'], chip: { icon: 'comment', label: 'No Workspace', description: 'No workspace: start the session in a temporary directory' }, icon: 'comment', @@ -145,7 +148,23 @@ suite('AgentHostWorktreeFolderChoices', () => { }, folderAgain: { items: ['[Folder] vscode (Current) *', '[Worktrees] feature', '[Worktrees] other', 'No Workspace'], chip: { icon: 'folder', label: 'vscode', description: 'Folder: vscode' } }, // No longer in a new worktree of the folder it no longer has - changes: ['isolation worktree', 'isolation folder', 'no workspace', 'folder /work/vscode'], + changes: ['isolation worktree', 'no workspace', 'isolation folder', 'folder /work/vscode'], + }); + }); + + test('No Workspace from New Worktree applies at once, without a folder, so a send right after it starts the session without one', async () => { + const { choices, actions, changes, configDirectories } = await createChoices({ enabled: true, at: primary, sessionsList: true }); + await actions(primary).find(action => action.label === 'New Worktree')!.run(); + // Not awaited, as the picker does not: a send can follow before anything it starts settles + const picked = actions(primary).find(action => action.label === 'No Workspace')!.run(); + const atOnce = { noWorkspace: choices.isNoWorkspaceSelected(), changes: [...changes], configDirectories: [...configDirectories] }; + await picked; + await new Promise(resolve => setTimeout(resolve, 0)); + + assert.deepStrictEqual({ atOnce, settled: { changes, configDirectories } }, { + atOnce: { noWorkspace: true, changes: ['isolation worktree', 'no workspace', 'isolation folder'], configDirectories: ['/work/vscode', 'none'] }, + // Nothing more once it settles, such as on a draft closed meanwhile + settled: { changes: ['isolation worktree', 'no workspace', 'isolation folder'], configDirectories: ['/work/vscode', 'none'] }, }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts index 993353ce714d3b..46b5169a73d847 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts @@ -668,6 +668,28 @@ suite('AgentHostUntitledProvisionalSessionService', () => { }); }); + test('a send right after No Workspace, before the host resolves its configuration, graduates the session without a working directory and with the configuration picked with it', async () => { + const ui = untitledChatUri('no-workspace-send'); + const real = ui.with({ path: '/real-no-workspace-send' }); + const folder = URI.file('/folder'); + folderService.setFolder(ui, folder); + await provisional.getOrCreate(ui, 'copilot', folder); + await provisional.applyConfigChange(ui, 'copilot', folder, { isolation: 'worktree' }); + // As the folder chip picks No Workspace: at once, without awaiting the host + folderService.setNoWorkspace(ui); + void provisional.applyConfigChange(ui, 'copilot', undefined, { isolation: 'folder' }); + const rebound = await provisional.tryRebind(ui, real, 'copilot'); + + const creation = agentHost.createCalls.find(call => call.session?.path === rebound?.path); + assert.deepStrictEqual({ + workingDirectories: creation?.workingDirectories, + isolation: creation?.config?.isolation, + }, { + workingDirectories: undefined, + isolation: 'folder', + }); + }); + test('disposing either UI mapping preserves the graduated backend', async () => { const ui = untitledChatUri('retained-backend-disposal'); const real = ui.with({ path: '/real-retained-backend-disposal' }); From a33b6608dda775edadc37053638d1297cdd228a4 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 19:29:41 +0100 Subject: [PATCH 142/165] Worktree support: the Chat view's folder chip ellipsizes a long name and gets the width it needs, so its neighbors overflow rather than being clipped Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostFolderPickerActionItem.ts | 24 ++++ .../media/agentHostChatInputPicker.css | 7 + .../browser/widget/input/chatInputPart.ts | 8 +- .../agentHostFolderPickerActionItem.test.ts | 120 ++++++++++++++++++ 4 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.test.ts diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts index 082161b4bc97b1..5f1acc9cbf8b73 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts @@ -31,6 +31,7 @@ import { IAgentHostNewSessionFolderService } from './agentHostNewSessionFolderSe import { IAgentHostCustomizationService } from './agentHostCustomizationService.js'; import { createFolderPickerTip } from './agentHostFolderPickerTip.js'; import { AgentHostWorktreeFolderChoices } from './agentHostWorktreeFolderChoices.js'; +import './media/agentHostChatInputPicker.css'; /** * Folder picker for agent-host sessions in multi-root windows. An agent-host @@ -45,6 +46,9 @@ import { AgentHostWorktreeFolderChoices } from './agentHostWorktreeFolderChoices export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewItem { private _hoverSetup = false; + private _container: HTMLElement | undefined; + /** Its width as last rendered with its name, which it keeps while hidden in the toolbar's overflow. */ + private _labelledWidth: number | undefined; /** The choices in a window with a single local folder while worktree support is on: New Worktree, the folder and its worktrees. */ private readonly _worktree: AgentHostWorktreeFolderChoices; @@ -178,6 +182,9 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt const label = chip ? chip.description : folder ? folder.name : (selected ? basename(selected) : localize('agentHost.selectFolder', "Folder")); const shownLabel = chip ? chip.label : label; const compact = !chip && this.pickerOptions.compact.get(); + // Never compact by its name, which a long one shows ellipsized + element.classList.toggle('compact', compact); + element.classList.toggle('agent-host-folder-chip', !!chip); element.classList.toggle('icon-only', compact || !shownLabel); dom.reset( element, @@ -193,6 +200,7 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt } override render(container: HTMLElement): void { + this._container = container; super.render(container); // The chip often shows a disabled folder name — the working directory is @@ -211,6 +219,22 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt } } + /** + * The width the chip needs while it always shows its name, which it does not give up to fit the toolbar as compact + * pickers do: for the toolbar's responsive budget, so that its neighbors overflow instead. `undefined` while it can be + * compact. + */ + getMinimumWidth(): number | undefined { + if (!this._worktree.isActive()) { + return undefined; + } + const width = this._container?.getBoundingClientRect().width; + if (width) { + this._labelledWidth = width; + } + return this._labelledWidth; + } + /** * Builds the hover content for the folder chip: the full folder path and, * when the folder maps to a git repository, the current branch name. diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css index e3c90c5ef33993..1b53df3087a845 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css @@ -175,3 +175,10 @@ opacity: 0.6; cursor: default; } + +/* The Chat view's folder chip always shows its name: a long one is ellipsized rather than clipping its neighbors */ +.interactive-session .chat-secondary-toolbar .chat-secondary-input-toolbar .chat-input-picker-item .action-label.agent-host-folder-chip .chat-input-picker-label { + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index cbe7a2921f467a..790cae405b9f4e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -4587,6 +4587,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return Math.max(0, laneWidth - genericChipsContainer.getBoundingClientRect().width - gap); }; const agentHostPermissions = this._register(new MutableDisposable()); + let folderPicker: AgentHostFolderPickerActionItem | undefined; if (this.options.renderSecondaryToolbar !== false) { this.secondaryToolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, responsivePickerContainer, MenuId.ChatInputSecondary, { telemetrySource: this.options.menus.telemetrySource, @@ -4598,7 +4599,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge kind: 'all', minItems: 1, actionMinWidth: 48, - getActionMinWidth: action => secondaryPickerMinWidths.get(action.id) ?? (secondaryPickerCompactStates.get(action.id)?.get() ? 22 : undefined), + // A folder chip that always shows its name needs its width, which it does not give up as compact pickers do + getActionMinWidth: action => (action.id === OpenAgentHostFolderPickerAction.ID ? folderPicker?.getMinimumWidth() : undefined) + ?? secondaryPickerMinWidths.get(action.id) ?? (secondaryPickerCompactStates.get(action.id)?.get() ? 22 : undefined), observedElement: responsivePickerContainer, getAvailableWidth: getSecondaryToolbarAvailableWidth, allowOverflow: () => this._secondaryPickerResponsiveLayout?.areAllItemsCompact() === true, @@ -4713,7 +4716,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } const createPicker = () => this.instantiationService.createInstance(AgentHostFolderPickerActionItem, action, widget, getSecondaryPickerOptions(action.id)); secondaryOverflowPickerHandlers.set(action.id, anchor => showOverflowPicker(createPicker, anchor)); - return createPicker(); + folderPicker = createPicker(); + return folderPicker; } else if (action.id === ChatSessionPrimaryPickerAction.ID && action instanceof MenuItemAction) { const createPicker = () => { const widgets = this.createChatSessionPickerWidgets(action, getSecondaryPickerOptions(action.id)); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.test.ts new file mode 100644 index 00000000000000..b9db5de8f44077 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.test.ts @@ -0,0 +1,120 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IContextMenuProvider } from '../../../../../../../base/browser/contextmenu.js'; +import { mainWindow } from '../../../../../../../base/browser/window.js'; +import { ToolBar } from '../../../../../../../base/browser/ui/toolbar/toolbar.js'; +import { Action } from '../../../../../../../base/common/actions.js'; +import { Event } from '../../../../../../../base/common/event.js'; +import { observableValue } from '../../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { MenuItemAction } from '../../../../../../../platform/actions/common/actions.js'; +import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IFolderLocation, IWorkspaceFolderLocationService } from '../../../../../../../platform/workspace/common/folderLocations.js'; +import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder, WorkbenchState } from '../../../../../../../platform/workspace/common/workspace.js'; +import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; +import { ISCMService } from '../../../../../scm/common/scm.js'; +import type { IChatWidget } from '../../../../browser/chat.js'; +import { IAgentHostCustomizationService } from '../../../../browser/agentSessions/agentHost/agentHostCustomizationService.js'; +import { AgentHostFolderPickerActionItem } from '../../../../browser/agentSessions/agentHost/agentHostFolderPickerActionItem.js'; +import { IAgentHostNewSessionFolderService } from '../../../../browser/agentSessions/agentHost/agentHostNewSessionFolderService.js'; +import { IAgentHostUntitledProvisionalSessionService } from '../../../../browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; +import { AgentHostWorktreeSessionSources, IAgentHostWorktreeSessionSources } from '../../../../browser/agentSessions/agentHost/agentHostWorktreeSessionSources.js'; +import { IAgentSessionsService } from '../../../../browser/agentSessions/agentSessionsService.js'; +import { SessionType } from '../../../../common/chatSessionsService.js'; +import { ChatConfiguration } from '../../../../common/constants.js'; +import '../../../../browser/widget/media/chat.css'; + +suite('AgentHostFolderPickerActionItem', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let root: HTMLElement; + setup(() => { + root = document.createElement('div'); + document.body.appendChild(root); + }); + teardown(() => root.remove()); + + test('in a narrow row, the chip keeps its name, ellipsized, and needs its width, so that its neighbors overflow rather than being clipped', () => { + const folder = URI.file('/work/a-repository-with-a-name-far-too-long-to-fit-in-the-chat-input-row'); + const session = URI.from({ scheme: SessionType.AgentHostCopilot, path: '/untitled-1' }); + const instantiationService = workbenchInstantiationService(undefined, store); + instantiationService.stub(IConfigurationService, new TestConfigurationService({ [ChatConfiguration.EditorWindowEnableWorktree]: true })); + instantiationService.stub(IWorkspaceContextService, upcastPartial({ + getWorkspace: () => upcastPartial({ uri: folder, folders: [upcastPartial({ uri: folder, name: 'a-repository-with-a-name-far-too-long-to-fit-in-the-chat-input-row' })] }), + getWorkbenchState: () => WorkbenchState.FOLDER, + onDidChangeWorkspaceFolders: Event.None, + })); + instantiationService.stub(IWorkspaceFolderLocationService, upcastPartial({ + onDidChangeProviders: Event.None, + onDidChangeLocation: Event.None, + canSetLocation: () => true, + getLocations: async () => [] as IFolderLocation[], + getLocation: () => undefined, + resolveLocation: async () => undefined, + })); + instantiationService.stub(IAgentHostNewSessionFolderService, upcastPartial({ onDidChangeFolder: Event.None, getFolder: () => folder, isNoWorkspace: () => false, getDefaultFolder: () => undefined })); + instantiationService.stub(IAgentHostUntitledProvisionalSessionService, upcastPartial({ onDidChange: Event.None, getResolvedConfig: () => undefined, getPreparedSessionConfig: () => undefined })); + instantiationService.stub(IAgentSessionsService, upcastPartial({ model: upcastPartial({ onDidChangeSessions: Event.None }) })); + instantiationService.stub(IAgentHostWorktreeSessionSources, store.add(new AgentHostWorktreeSessionSources())); + instantiationService.stub(IAgentHostCustomizationService, upcastPartial({ onDidChangeCustomizations: Event.None, getClientWorkingDirectoryUris: () => [] })); + instantiationService.stub(ISCMService, upcastPartial({ getRepository: () => undefined })); + const widget = upcastPartial({ viewModel: upcastPartial>({ sessionResource: session }), onDidChangeViewModel: Event.None }); + + // As the chat input's secondary row lays it out + root.className = 'interactive-session'; + const row = document.createElement('div'); + row.className = 'chat-secondary-toolbar'; + root.appendChild(row); + const lane = document.createElement('div'); + lane.className = 'chat-secondary-input-toolbar'; + row.appendChild(lane); + const folderAction = store.add(new Action('workbench.action.chat.openAgentHostFolderPicker', 'Folder')); + const neighbors = [store.add(new Action('copilot', 'Copilot')), store.add(new Action('permissions', 'Manual permissions'))]; + const compact = observableValue('compact', true); + let chip: AgentHostFolderPickerActionItem | undefined; + let availableWidth = 600; + const contextMenuProvider: IContextMenuProvider = { showContextMenu: () => { } }; + const toolbar = store.add(new ToolBar(lane, contextMenuProvider, { + responsiveBehavior: { + enabled: true, + kind: 'all', + minItems: 1, + actionMinWidth: 48, + // As the chat input budgets it, and compact neighbors at their control width + getActionMinWidth: action => (action.id === folderAction.id ? chip?.getMinimumWidth() : undefined) ?? 22, + getAvailableWidth: () => availableWidth, + }, + actionViewItemProvider: action => action.id === folderAction.id + ? chip = instantiationService.createInstance(AgentHostFolderPickerActionItem, upcastPartial(folderAction), widget, { compact }) + : undefined, + })); + toolbar.setActions([folderAction, ...neighbors]); + toolbar.relayout(); + const label = lane.querySelector('.agent-host-folder-chip .chat-input-picker-label'); + const chipWidth = chip?.getMinimumWidth() ?? 0; + // Room for the chip with its name, but not for its neighbors as well + availableWidth = 200; + toolbar.relayout(); + + assert.deepStrictEqual({ + label: label?.textContent, + ellipsized: !!label && label.scrollWidth > label.clientWidth && mainWindow.getComputedStyle(label).textOverflow === 'ellipsis', + bounded: !!label && label.getBoundingClientRect().width <= 160, + needsMoreThanCompact: chipWidth > 22, + overflowed: toolbar.hasOverflow(), + }, { + label: 'a-repository-with-a-name-far-too-long-to-fit-in-the-chat-input-row', + ellipsized: true, + bounded: true, + needsMoreThanCompact: true, + overflowed: true, + }); + }); +}); From ef00f287fb2c9bbaeb72e6add882c3a5d6d216c8 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 19:51:50 +0100 Subject: [PATCH 143/165] Worktree support: the Chat view's folder chip needs the width of the name it shows now, also from the overflow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostFolderPickerActionItem.ts | 64 +++++++++++--- .../agentHostFolderPickerActionItem.test.ts | 83 ++++++++++++++++++- 2 files changed, 135 insertions(+), 12 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts index 5f1acc9cbf8b73..34117c6ce43f8a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts @@ -13,6 +13,8 @@ import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { IDisposable } from '../../../../../../base/common/lifecycle.js'; import { basename } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { mainWindow } from '../../../../../../base/browser/window.js'; import { localize } from '../../../../../../nls.js'; import { MenuItemAction } from '../../../../../../platform/actions/common/actions.js'; import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; @@ -43,12 +45,26 @@ import './media/agentHostChatInputPicker.css'; * directory is fixed, so the chip stays visible (showing the chosen folder) * but is disabled via the action's precondition. */ +/** As `.agent-host-folder-chip .chat-input-picker-label` bounds the name in agentHostChatInputPicker.css. */ +const FOLDER_CHIP_LABEL_MAX_WIDTH = 160; + +let textMeasureContext: CanvasRenderingContext2D | null | undefined; +function measureTextWidth(text: string, font: string): number { + textMeasureContext ??= mainWindow.document.createElement('canvas').getContext('2d'); + if (!textMeasureContext) { + return 0; + } + textMeasureContext.font = font; + return textMeasureContext.measureText(text).width; +} + export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewItem { private _hoverSetup = false; private _container: HTMLElement | undefined; - /** Its width as last rendered with its name, which it keeps while hidden in the toolbar's overflow. */ - private _labelledWidth: number | undefined; + private _labelElement: HTMLElement | undefined; + /** What the chip needs besides its name, its icon, gap and padding, and its name's font, as last rendered. */ + private _chrome: { readonly width: number; readonly font: string } | undefined; /** The choices in a window with a single local folder while worktree support is on: New Worktree, the folder and its worktrees. */ private readonly _worktree: AgentHostWorktreeFolderChoices; @@ -176,9 +192,7 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt protected override renderLabel(element: HTMLElement): IDisposable | null { const selected = this._selectedFolder(); const folder = selected && this._workspaceContextService.getWorkspace().folders.find(f => f.uri.toString() === selected.toString()); - // With worktree support, by its icon and name, a folder, a worktree, New Worktree or No Workspace, also while compact - const chip = this._worktree?.isActive() && this._worktree.isNoWorkspaceSelected() ? this._worktree.getNoWorkspaceChip() - : selected && this._worktree?.isActive() ? this._worktree.getChip(selected) : undefined; + const chip = this._chip(selected); const label = chip ? chip.description : folder ? folder.name : (selected ? basename(selected) : localize('agentHost.selectFolder', "Folder")); const shownLabel = chip ? chip.label : label; const compact = !chip && this.pickerOptions.compact.get(); @@ -186,10 +200,11 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt element.classList.toggle('compact', compact); element.classList.toggle('agent-host-folder-chip', !!chip); element.classList.toggle('icon-only', compact || !shownLabel); + this._labelElement = !compact && shownLabel ? dom.$('span.chat-input-picker-label', undefined, shownLabel) : undefined; dom.reset( element, ...renderLabelWithIcons(chip?.icon === Codicon.worktree || chip?.icon === Codicon.comment ? `$(${chip.icon.id})` : `$(folder-compact)`), - ...(!compact && shownLabel ? [dom.$('span.chat-input-picker-label', undefined, shownLabel)] : []), + ...(this._labelElement ? [this._labelElement] : []), ); // Set the aria label after the visible text is in place: the base class // derives it from `element.textContent`, so labeling first would lag one @@ -225,14 +240,41 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt * compact. */ getMinimumWidth(): number | undefined { - if (!this._worktree.isActive()) { + const chip = this._chip(this._selectedFolder()); + if (!chip) { return undefined; } - const width = this._container?.getBoundingClientRect().width; - if (width) { - this._labelledWidth = width; + this._measureChrome(); + // Of the name it shows now, also while in the overflow, rather than of the width the toolbar gave it + return this._chrome && Math.ceil(this._chrome.width + Math.min(measureTextWidth(chip.label, this._chrome.font), FOLDER_CHIP_LABEL_MAX_WIDTH)); + } + + /** With worktree support, the chip by its icon and name: a folder, a worktree, New Worktree or No Workspace, also while compact. */ + private _chip(selected: URI | undefined): { readonly icon: ThemeIcon; readonly label: string; readonly description: string } | undefined { + if (!this._worktree?.isActive()) { + return undefined; + } + if (this._worktree.isNoWorkspaceSelected()) { + return this._worktree.getNoWorkspaceChip(); + } + return selected && this._worktree.getChip(selected); + } + + private _measureChrome(): void { + const element = this.element; + const container = this._container; + const label = this._labelElement; + if (!element?.isConnected || !container || !label) { + return; } - return this._labelledWidth; + const window = dom.getWindow(container); + const px = (value: string) => Number.parseFloat(value) || 0; + const containerStyle = window.getComputedStyle(container); + const elementStyle = window.getComputedStyle(element); + const width = element.getBoundingClientRect().width - label.getBoundingClientRect().width + + px(elementStyle.marginLeft) + px(elementStyle.marginRight) + + px(containerStyle.paddingLeft) + px(containerStyle.paddingRight) + px(containerStyle.borderLeftWidth) + px(containerStyle.borderRightWidth); + this._chrome = { width, font: window.getComputedStyle(label).font }; } /** diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.test.ts index b9db5de8f44077..948407f76eefef 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.test.ts @@ -8,7 +8,7 @@ import { IContextMenuProvider } from '../../../../../../../base/browser/contextm import { mainWindow } from '../../../../../../../base/browser/window.js'; import { ToolBar } from '../../../../../../../base/browser/ui/toolbar/toolbar.js'; import { Action } from '../../../../../../../base/common/actions.js'; -import { Event } from '../../../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../../../base/common/event.js'; import { observableValue } from '../../../../../../../base/common/observable.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../../../base/test/common/mock.js'; @@ -117,4 +117,85 @@ suite('AgentHostFolderPickerActionItem', () => { overflowed: true, }); }); + test('last in a narrow row, it needs the width of the name it shows now: a shorter name, or No Workspace, brings it back from the overflow', () => { + const primary = URI.file('/work/vscode'); + const longName = URI.file('/work/a-worktree-with-a-name-far-too-long-to-fit-in-the-chat-input-row'); + const shortName = URI.file('/work/fix'); + const session = URI.from({ scheme: SessionType.AgentHostCopilot, path: '/untitled-1' }); + let selected = shortName; + let noWorkspace = false; + const onDidChangeFolder = store.add(new Emitter()); + const instantiationService = workbenchInstantiationService(undefined, store); + instantiationService.stub(IConfigurationService, new TestConfigurationService({ [ChatConfiguration.EditorWindowEnableWorktree]: true })); + instantiationService.stub(IWorkspaceContextService, upcastPartial({ + getWorkspace: () => upcastPartial({ uri: primary, folders: [upcastPartial({ uri: primary, name: 'vscode' })] }), + getWorkbenchState: () => WorkbenchState.FOLDER, + onDidChangeWorkspaceFolders: Event.None, + })); + instantiationService.stub(IWorkspaceFolderLocationService, upcastPartial({ + onDidChangeProviders: Event.None, + onDidChangeLocation: Event.None, + canSetLocation: () => true, + getLocations: async () => [] as IFolderLocation[], + getLocation: () => undefined, + resolveLocation: async () => undefined, + })); + instantiationService.stub(IAgentHostNewSessionFolderService, upcastPartial({ onDidChangeFolder: onDidChangeFolder.event, getFolder: () => noWorkspace ? undefined : selected, isNoWorkspace: () => noWorkspace, getDefaultFolder: () => undefined })); + instantiationService.stub(IAgentHostUntitledProvisionalSessionService, upcastPartial({ onDidChange: Event.None, getResolvedConfig: () => undefined, getPreparedSessionConfig: () => undefined })); + instantiationService.stub(IAgentSessionsService, upcastPartial({ model: upcastPartial({ onDidChangeSessions: Event.None }) })); + instantiationService.stub(IAgentHostWorktreeSessionSources, store.add(new AgentHostWorktreeSessionSources())); + instantiationService.stub(IAgentHostCustomizationService, upcastPartial({ onDidChangeCustomizations: Event.None, getClientWorkingDirectoryUris: () => [] })); + instantiationService.stub(ISCMService, upcastPartial({ getRepository: () => undefined })); + const widget = upcastPartial({ viewModel: upcastPartial>({ sessionResource: session }), onDidChangeViewModel: Event.None }); + + root.className = 'interactive-session'; + const row = document.createElement('div'); + row.className = 'chat-secondary-toolbar'; + root.appendChild(row); + const lane = document.createElement('div'); + lane.className = 'chat-secondary-input-toolbar'; + row.appendChild(lane); + const folderAction = store.add(new Action('workbench.action.chat.openAgentHostFolderPicker', 'Folder')); + const neighbors = [store.add(new Action('copilot', 'Copilot')), store.add(new Action('permissions', 'Manual permissions'))]; + let chip: AgentHostFolderPickerActionItem | undefined; + const contextMenuProvider: IContextMenuProvider = { showContextMenu: () => { } }; + const toolbar = store.add(new ToolBar(lane, contextMenuProvider, { + responsiveBehavior: { + enabled: true, + kind: 'all', + minItems: 1, + actionMinWidth: 48, + getActionMinWidth: action => (action.id === folderAction.id ? chip?.getMinimumWidth() : undefined) ?? 22, + // Room for its neighbors and a chip with a short name, not a long one + getAvailableWidth: () => 160, + }, + actionViewItemProvider: action => action.id === folderAction.id + ? chip = instantiationService.createInstance(AgentHostFolderPickerActionItem, upcastPartial(folderAction), widget, { compact: observableValue('compact', true) }) + : undefined, + })); + // Last, as in the chat input + toolbar.setActions([...neighbors, folderAction]); + const select = (folder: URI | 'none') => { + noWorkspace = folder === 'none'; + selected = folder === 'none' ? selected : folder; + onDidChangeFolder.fire(session); + toolbar.relayout(); + return { minWidth: chip?.getMinimumWidth() ?? 0, overflowed: toolbar.hasOverflow() }; + }; + + const short = select(shortName); + const long = select(longName); + // Changed while in the overflow + const shortAgain = select(shortName); + const none = select('none'); + + assert.deepStrictEqual({ + short: short.overflowed, + long: long.overflowed, + shortAgain: shortAgain.overflowed, + none: none.overflowed, + shorterNeedsLess: shortAgain.minWidth < long.minWidth, + sameAsBefore: shortAgain.minWidth === short.minWidth, + }, { short: false, long: true, shortAgain: false, none: false, shorterNeedsLess: true, sameAsBefore: true }); + }); }); From a9191a01bd620e63980265c4c19ecf531ee74780 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 19:55:43 +0100 Subject: [PATCH 144/165] Worktree support: test that the folder chip's width follows its name also while it stays in the row Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostFolderPickerActionItem.test.ts | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.test.ts index 948407f76eefef..7dd712818217af 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.test.ts @@ -117,7 +117,8 @@ suite('AgentHostFolderPickerActionItem', () => { overflowed: true, }); }); - test('last in a narrow row, it needs the width of the name it shows now: a shorter name, or No Workspace, brings it back from the overflow', () => { + /** A chat input's secondary row of `availableWidth`, with two neighbors and the folder chip last, as in the chat input. */ + function createRow(availableWidth: number) { const primary = URI.file('/work/vscode'); const longName = URI.file('/work/a-worktree-with-a-name-far-too-long-to-fit-in-the-chat-input-row'); const shortName = URI.file('/work/fix'); @@ -166,8 +167,7 @@ suite('AgentHostFolderPickerActionItem', () => { minItems: 1, actionMinWidth: 48, getActionMinWidth: action => (action.id === folderAction.id ? chip?.getMinimumWidth() : undefined) ?? 22, - // Room for its neighbors and a chip with a short name, not a long one - getAvailableWidth: () => 160, + getAvailableWidth: () => availableWidth, }, actionViewItemProvider: action => action.id === folderAction.id ? chip = instantiationService.createInstance(AgentHostFolderPickerActionItem, upcastPartial(folderAction), widget, { compact: observableValue('compact', true) }) @@ -179,10 +179,21 @@ suite('AgentHostFolderPickerActionItem', () => { noWorkspace = folder === 'none'; selected = folder === 'none' ? selected : folder; onDidChangeFolder.fire(session); + // The min width the toolbar applied for the name before, as the chip measures the new one + let slot = chip?.element; + while (slot && !slot.classList.contains('action-item')) { + slot = slot.parentElement ?? undefined; + } + const floor = Number.parseFloat(slot?.style.minWidth ?? '') || 0; toolbar.relayout(); - return { minWidth: chip?.getMinimumWidth() ?? 0, overflowed: toolbar.hasOverflow() }; + return { minWidth: chip?.getMinimumWidth() ?? 0, overflowed: toolbar.hasOverflow(), floor }; }; + return { select, chip: () => chip, longName, shortName }; + } + test('last in a narrow row, it needs the width of the name it shows now: a shorter name, or No Workspace, brings it back from the overflow', () => { + // Room for its neighbors and a chip with a short name, not a long one + const { select, longName, shortName } = createRow(160); const short = select(shortName); const long = select(longName); // Changed while in the overflow @@ -198,4 +209,21 @@ suite('AgentHostFolderPickerActionItem', () => { sameAsBefore: shortAgain.minWidth === short.minWidth, }, { short: false, long: true, shortAgain: false, none: false, shorterNeedsLess: true, sameAsBefore: true }); }); + test('in a wide row, its width follows its name also while it stays in the row: a shorter name needs what a fresh chip with it does', () => { + // Room for the chip with a long name too, which the toolbar then gives a width the shorter name does not need + const { select, longName, shortName } = createRow(1000); + const fresh = select(shortName); + const long = select(longName); + const short = select(shortName); + const none = select('none'); + + assert.deepStrictEqual({ + overflowed: [fresh.overflowed, long.overflowed, short.overflowed, none.overflowed], + longNeedsMore: long.minWidth > fresh.minWidth, + // Measured while still held at the long name's width + measuredWithinLongFloor: short.floor === long.minWidth, + shortAsFresh: short.minWidth === fresh.minWidth, + noWorkspaceLessThanLong: none.minWidth < long.minWidth, + }, { overflowed: [false, false, false, false], longNeedsMore: true, measuredWithinLongFloor: true, shortAsFresh: true, noWorkspaceLessThanLong: true }); + }); }); From 48f77400821a6262bbb93c8092256c76c9a21895 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 20:00:16 +0100 Subject: [PATCH 145/165] Worktree support: the Chat view's folder chip measures what it needs besides its name from what does not give way as the row narrows it Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostFolderPickerActionItem.ts | 25 ++++++++++++--- .../agentHostFolderPickerActionItem.test.ts | 31 ++++++++++++++++++- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts index 34117c6ce43f8a..3d94a29eb2f299 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts @@ -260,6 +260,10 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt return selected && this._worktree.getChip(selected); } + /** + * Measures what the chip needs besides its name from what does not give way as the toolbar narrows it, its icon, + * gaps, margins, paddings and borders, rather than from its width, which is less than it needs while narrowed. + */ private _measureChrome(): void { const element = this.element; const container = this._container; @@ -269,12 +273,23 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt } const window = dom.getWindow(container); const px = (value: string) => Number.parseFloat(value) || 0; - const containerStyle = window.getComputedStyle(container); + const edges = (style: CSSStyleDeclaration) => px(style.paddingLeft) + px(style.paddingRight) + px(style.borderLeftWidth) + px(style.borderRightWidth); const elementStyle = window.getComputedStyle(element); - const width = element.getBoundingClientRect().width - label.getBoundingClientRect().width - + px(elementStyle.marginLeft) + px(elementStyle.marginRight) - + px(containerStyle.paddingLeft) + px(containerStyle.paddingRight) + px(containerStyle.borderLeftWidth) + px(containerStyle.borderRightWidth); - this._chrome = { width, font: window.getComputedStyle(label).font }; + const labelStyle = window.getComputedStyle(label); + let width = px(labelStyle.marginLeft) + px(labelStyle.marginRight) + px(elementStyle.columnGap) * Math.max(0, element.children.length - 1); + for (const child of element.children) { + if (child !== label) { + const childStyle = window.getComputedStyle(child); + width += child.getBoundingClientRect().width + px(childStyle.marginLeft) + px(childStyle.marginRight); + } + } + // The element and what holds it in the toolbar's item + for (let node: HTMLElement | null = element; node && node !== container; node = node.parentElement) { + const style = node === element ? elementStyle : window.getComputedStyle(node); + width += edges(style) + px(style.marginLeft) + px(style.marginRight); + } + width += edges(window.getComputedStyle(container)); + this._chrome = { width, font: labelStyle.font }; } /** diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.test.ts index 7dd712818217af..69efe1efeed61d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.test.ts @@ -155,6 +155,8 @@ suite('AgentHostFolderPickerActionItem', () => { root.appendChild(row); const lane = document.createElement('div'); lane.className = 'chat-secondary-input-toolbar'; + // As wide as the toolbar is told it is, so that what it lays out is what it measures + lane.style.width = `${availableWidth}px`; row.appendChild(lane); const folderAction = store.add(new Action('workbench.action.chat.openAgentHostFolderPicker', 'Folder')); const neighbors = [store.add(new Action('copilot', 'Copilot')), store.add(new Action('permissions', 'Manual permissions'))]; @@ -188,7 +190,7 @@ suite('AgentHostFolderPickerActionItem', () => { toolbar.relayout(); return { minWidth: chip?.getMinimumWidth() ?? 0, overflowed: toolbar.hasOverflow(), floor }; }; - return { select, chip: () => chip, longName, shortName }; + return { select, chip: () => chip, lane, longName, shortName }; } test('last in a narrow row, it needs the width of the name it shows now: a shorter name, or No Workspace, brings it back from the overflow', () => { @@ -226,4 +228,31 @@ suite('AgentHostFolderPickerActionItem', () => { noWorkspaceLessThanLong: none.minWidth < long.minWidth, }, { overflowed: [false, false, false, false], longNeedsMore: true, measuredWithinLongFloor: true, shortAsFresh: true, noWorkspaceLessThanLong: true }); }); + test('at a narrow width it really is, a long name, fresh or after a short one, needs what it does in a wide row, without overlapping its neighbors', () => { + const wideRow = createRow(1000); + const wide = { short: wideRow.select(wideRow.shortName).minWidth, long: wideRow.select(wideRow.longName).minWidth }; + root.textContent = ''; + const { select, lane, longName, shortName } = createRow(160); + /** Whether the items it shows lie within it, one after another. */ + const laidOut = () => { + const laneBounds = lane.getBoundingClientRect(); + const bounds = [...lane.getElementsByClassName('action-item')].map(item => item.getBoundingClientRect()).filter(rect => rect.width > 0); + return bounds.every((rect, index) => rect.left >= laneBounds.left - 0.5 && rect.right <= laneBounds.right + 0.5 && (index === 0 || rect.left >= bounds[index - 1].right - 0.5)); + }; + // Narrowed by the row as it first renders, before the toolbar moves it to the overflow + const freshLong = { ...select(longName), laidOut: laidOut() }; + const short = { ...select(shortName), laidOut: laidOut() }; + const long = { ...select(longName), laidOut: laidOut() }; + const shortAgain = { ...select(shortName), laidOut: laidOut() }; + + assert.deepStrictEqual({ + minWidths: [freshLong.minWidth, short.minWidth, long.minWidth, shortAgain.minWidth], + overflowed: [freshLong.overflowed, short.overflowed, long.overflowed, shortAgain.overflowed], + laidOut: [freshLong.laidOut, short.laidOut, long.laidOut, shortAgain.laidOut], + }, { + minWidths: [wide.long, wide.short, wide.long, wide.short], + overflowed: [true, false, true, false], + laidOut: [true, true, true, true], + }); + }); }); From cf97880cde8b91eb10751b50e9f27a9af2fee3cf Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 20:49:51 +0100 Subject: [PATCH 146/165] Worktree support: the Chat view's folder chip shows No Workspace for a started or restored session without one, rather than its scratch folder Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostCustomizationService.ts | 12 ++++++++- .../agentHostFolderPickerActionItem.ts | 15 ++++++++--- .../agentHostFolderPickerActionItem.test.ts | 26 ++++++++++++++++--- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts index b211049c5e35be..ca0861e65c7504 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts @@ -30,7 +30,7 @@ import { type IAgentSubscription } from '../../../../../../platform/agentHost/co import { AuthenticateTargetKind } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; import { CustomizationEnablementKind, CustomizationLoadStatus, CustomizationType, McpServerCustomization, McpServerStatus, type Customization, type CustomizationEnablement, type McpServerState, type PluginCustomization, type RootConfigState, type SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { AgentCustomization, DEFAULT_CHAT_ID, ROOT_STATE_URI, StateComponents, getSessionChatResource, readSessionFolderPickerDecision, type ISessionFolderPickerDecision } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { AgentCustomization, DEFAULT_CHAT_ID, ROOT_STATE_URI, StateComponents, getSessionChatResource, readSessionFolderPickerDecision, readSessionWorkspaceless, type ISessionFolderPickerDecision } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { InstantiationType, registerSingleton } from '../../../../../../platform/instantiation/common/extensions.js'; import { createDecorator, IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IMcpServerConfiguration } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js'; @@ -94,6 +94,9 @@ export interface IAgentHostCustomizationService { /** The ordered roots in the client's URI space for filesystem access. */ getClientWorkingDirectoryUris(sessionResource: URI): readonly URI[]; + /** Whether the started session has no workspace, working in a scratch folder the agent host created for it. */ + isWorkspaceless?(sessionResource: URI): boolean; + /** Returns the host-advertised chat URI for the selected client chat resource. */ getBackendChatResource?(sessionResource: URI): URI | undefined; @@ -215,6 +218,8 @@ export interface IAgentHostCustomizationTarget { readonly workingDirectories?: readonly string[]; /** Client-space roots, including provisional roots. */ readonly clientWorkingDirectories?: readonly URI[]; + /** Whether the session has no workspace, so that its working directory is a scratch folder. */ + readonly workspaceless?: boolean; readonly rootConfig?: RootConfigState; isBundledMcpServer(pluginUri: string, serverName: string): boolean; authenticate(request: IAgentHostAuthenticateRequest): Promise; @@ -331,6 +336,10 @@ export abstract class AbstractAgentHostCustomizationService extends Disposable i return target.workingDirectories?.map(root => target.resourceUris.fromAgentHost(URI.parse(root))) ?? []; } + isWorkspaceless(sessionResource: URI): boolean { + return this._resolveTarget(sessionResource)?.workspaceless === true; + } + getClientPluginUri(sessionResource: URI, plugin: Pick): URI | undefined { const target = this._resolveTarget(sessionResource); return target && plugin.clientId !== undefined ? getAgentHostCustomizationUri(URI.parse(plugin.uri), target, plugin) : undefined; @@ -1150,6 +1159,7 @@ export class WorkbenchAgentHostCustomizationService extends AbstractAgentHostCus workingDirectory: workingDirectories[0], workingDirectories, clientWorkingDirectories, + workspaceless: readSessionWorkspaceless(sessionState?._meta), rootConfig: rootState && !(rootState instanceof Error) ? rootState.config : undefined, isBundledMcpServer: (pluginUri, serverName) => this._activeClientService.isBundledMcpServer(pluginUri, serverName), authenticate: request => target.connection.authenticate(request), diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts index 3d94a29eb2f299..27c7fdf469aecf 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts @@ -167,7 +167,7 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt private _selectedFolder(): URI | undefined { const folders = this._workspaceContextService.getWorkspace().folders; const sessionResource = this._sessionResource(); - if (sessionResource && this._newSessionFolderService.isNoWorkspace(sessionResource)) { + if (this._isNoWorkspace()) { return undefined; } const stored = sessionResource ? this._newSessionFolderService.getFolder(sessionResource) : undefined; @@ -254,7 +254,7 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt if (!this._worktree?.isActive()) { return undefined; } - if (this._worktree.isNoWorkspaceSelected()) { + if (this._isNoWorkspace()) { return this._worktree.getNoWorkspaceChip(); } return selected && this._worktree.getChip(selected); @@ -264,6 +264,15 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt * Measures what the chip needs besides its name from what does not give way as the toolbar narrows it, its icon, * gaps, margins, paddings and borders, rather than from its width, which is less than it needs while narrowed. */ + /** + * Whether the session has no workspace: as chosen for a new chat, or as the started or restored session is, which works in + * a scratch folder the agent host created for it rather than in a folder to show. + */ + private _isNoWorkspace(): boolean { + const sessionResource = this._sessionResource(); + return !!sessionResource && (this._newSessionFolderService.isNoWorkspace(sessionResource) || this._customizationService.isWorkspaceless?.(sessionResource) === true); + } + private _measureChrome(): void { const element = this.element; const container = this._container; @@ -298,7 +307,7 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt * Returns `undefined` when no folder is selected so no hover is shown. */ private _buildHoverContent(): IManagedHoverTooltipMarkdownString | undefined { - if (this._worktree.isActive() && this._worktree.isNoWorkspaceSelected()) { + if (this._worktree.isActive() && this._isNoWorkspace()) { const { description } = this._worktree.getNoWorkspaceChip(); return { markdown: new MarkdownString().appendText(description), markdownNotSupportedFallback: description }; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.test.ts index 69efe1efeed61d..c5b79058599720 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.test.ts @@ -118,7 +118,7 @@ suite('AgentHostFolderPickerActionItem', () => { }); }); /** A chat input's secondary row of `availableWidth`, with two neighbors and the folder chip last, as in the chat input. */ - function createRow(availableWidth: number) { + function createRow(availableWidth: number, started?: { readonly workspaceless: boolean }) { const primary = URI.file('/work/vscode'); const longName = URI.file('/work/a-worktree-with-a-name-far-too-long-to-fit-in-the-chat-input-row'); const shortName = URI.file('/work/fix'); @@ -141,11 +141,17 @@ suite('AgentHostFolderPickerActionItem', () => { getLocation: () => undefined, resolveLocation: async () => undefined, })); - instantiationService.stub(IAgentHostNewSessionFolderService, upcastPartial({ onDidChangeFolder: onDidChangeFolder.event, getFolder: () => noWorkspace ? undefined : selected, isNoWorkspace: () => noWorkspace, getDefaultFolder: () => undefined })); + instantiationService.stub(IAgentHostNewSessionFolderService, upcastPartial({ onDidChangeFolder: onDidChangeFolder.event, getFolder: () => noWorkspace || started ? undefined : selected, isNoWorkspace: () => noWorkspace, getDefaultFolder: () => undefined })); instantiationService.stub(IAgentHostUntitledProvisionalSessionService, upcastPartial({ onDidChange: Event.None, getResolvedConfig: () => undefined, getPreparedSessionConfig: () => undefined })); instantiationService.stub(IAgentSessionsService, upcastPartial({ model: upcastPartial({ onDidChangeSessions: Event.None }) })); instantiationService.stub(IAgentHostWorktreeSessionSources, store.add(new AgentHostWorktreeSessionSources())); - instantiationService.stub(IAgentHostCustomizationService, upcastPartial({ onDidChangeCustomizations: Event.None, getClientWorkingDirectoryUris: () => [] })); + // A started session, such as a restored one, which works in the scratch folder the agent host created for it if it has no workspace + const scratch = URI.file('/home/.copilot/chats/3e215af6-5a8e-4060-b25e-0d2f1c9a7b41'); + instantiationService.stub(IAgentHostCustomizationService, upcastPartial({ + onDidChangeCustomizations: Event.None, + getClientWorkingDirectoryUris: () => started ? [started.workspaceless ? scratch : selected] : [], + isWorkspaceless: () => started?.workspaceless === true, + })); instantiationService.stub(ISCMService, upcastPartial({ getRepository: () => undefined })); const widget = upcastPartial({ viewModel: upcastPartial>({ sessionResource: session }), onDidChangeViewModel: Event.None }); @@ -255,4 +261,18 @@ suite('AgentHostFolderPickerActionItem', () => { laidOut: [true, true, true, true], }); }); + test('a started or restored session without a workspace shows No Workspace, not the scratch folder it works in, and one with a folder shows the folder', () => { + const shown = (workspaceless: boolean) => { + root.textContent = ''; + const { chip } = createRow(1000, { workspaceless }); + const element = chip()?.element; + const icon = element?.firstElementChild; + return { label: element?.textContent, ariaLabel: element?.ariaLabel, noWorkspaceIcon: !!icon?.classList.contains('codicon-comment') }; + }; + + assert.deepStrictEqual({ workspaceless: shown(true), inFolder: shown(false) }, { + workspaceless: { label: 'No Workspace', ariaLabel: 'No workspace: start the session in a temporary directory', noWorkspaceIcon: true }, + inFolder: { label: 'fix', ariaLabel: 'Folder: fix', noWorkspaceIcon: false }, + }); + }); }); From 5261ebb2a5a3f16525333c00dfef93b65093ec21 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 20:51:50 +0100 Subject: [PATCH 147/165] Worktree support: the Chat view's folder picker offers No Workspace and the folder first, then New Worktree and the worktrees under Worktrees Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostWorktreeFolderChoices.ts | 30 +++++++++++-------- .../agentHostWorktreeFolderChoices.test.ts | 29 +++++++++--------- 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts index bb60f799460ecb..bcb567bc8499da 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts @@ -171,10 +171,13 @@ export class AgentHostWorktreeFolderChoices extends Disposable { getActions(action: MenuItemAction, selected: URI | undefined, onDidPick: () => void): IActionWidgetDropdownAction[] { const noWorkspaceSelected = this.isNoWorkspaceSelected(); const newWorktreeSelected = !noWorkspaceSelected && this.isNewWorktreeSelected(); - const actions: IActionWidgetDropdownAction[] = []; + // The folder, and what has none, first; then the worktrees, a new one first + const workspaceGroup = { label: '', order: 0 }; + const worktreesGroup = { label: localize('agentHost.folderPicker.worktrees', "Worktrees"), order: 1, showHeader: true }; + let newWorktree: IActionWidgetDropdownAction | undefined; // A new worktree is of the selected folder, which No Workspace does not have if (!noWorkspaceSelected && this._isolation()?.worktree) { - actions.push({ + newWorktree = { ...action, id: 'agentHostFolder.newWorktree', label: getNewWorktreeLabel(), @@ -182,17 +185,18 @@ export class AgentHostWorktreeFolderChoices extends Disposable { checked: newWorktreeSelected, icon: WORKTREE_ICON, enabled: true, - category: { label: '', order: 0 }, + category: worktreesGroup, run: async () => { await this._setIsolation(true); onDidPick(); }, - }); + }; } - const sections = groupFolderLocations(this._choices().map(choice => ({ item: choice, kind: choice.kind, current: choice.current }))); - sections.forEach((section, index) => { + const folders: IActionWidgetDropdownAction[] = []; + const worktrees: IActionWidgetDropdownAction[] = []; + for (const section of groupFolderLocations(this._choices().map(choice => ({ item: choice, kind: choice.kind, current: choice.current })))) { for (const choice of section.items) { - actions.push({ + (section.kind === 'worktree' ? worktrees : folders).push({ ...action, id: `agentHostFolder.${choice.uri.toString()}`, label: choice.label, @@ -201,7 +205,7 @@ export class AgentHostWorktreeFolderChoices extends Disposable { icon: choice.kind === 'worktree' ? WORKTREE_ICON : Codicon.folder, enabled: true, tooltip: choice.uri.fsPath, - category: { label: section.title, order: index + 1, showHeader: true }, + category: section.kind === 'worktree' ? worktreesGroup : workspaceGroup, run: async () => { const sessionResource = this._sessionResource(); // Read before the folder changes, which resets the session's configuration @@ -214,9 +218,10 @@ export class AgentHostWorktreeFolderChoices extends Disposable { }, }); } - }); + } + let noWorkspace: IActionWidgetDropdownAction | undefined; if (this.isNoWorkspaceAvailable() || noWorkspaceSelected) { - actions.push({ + noWorkspace = { ...action, id: 'agentHostFolder.noWorkspace', label: getNoWorkspaceLabel(), @@ -224,7 +229,7 @@ export class AgentHostWorktreeFolderChoices extends Disposable { checked: noWorkspaceSelected, icon: NO_WORKSPACE_ICON, enabled: true, - category: { label: '', order: sections.length + 1 }, + category: workspaceGroup, run: () => { const sessionResource = this._sessionResource(); const provider = sessionResource && getLocalAgentHostSessionProvider(sessionResource); @@ -241,8 +246,9 @@ export class AgentHostWorktreeFolderChoices extends Disposable { } onDidPick(); }, - }); + }; } + const actions = [...(noWorkspace ? [noWorkspace] : []), ...folders, ...(newWorktree ? [newWorktree] : []), ...worktrees]; return actions; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts index a5d9b6ed691a2f..44fb48404d78bf 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts @@ -84,17 +84,17 @@ suite('AgentHostWorktreeFolderChoices', () => { return { choices, actions, items, changes, configDirectories, listed, onDidChangeSessions }; } - test('offers New Worktree first, then the folder and its worktrees, each section with where the folder is now first', async () => { + test('offers the folder, then under Worktrees New Worktree first and the worktrees, each with where the folder is now first', async () => { const atWorktree = await createChoices({ enabled: true, at: other }); const atFolder = await createChoices({ enabled: true, at: primary }); // A worktree that no session lists any more, such as one whose sessions are archived const atUnlisted = await createChoices({ enabled: true, at: other, listed: [feature] }); assert.deepStrictEqual({ atWorktree: atWorktree.items(other), icons: atWorktree.actions(other).map(action => action.icon?.id), atUnlisted: atUnlisted.items(other), atFolder: atFolder.items(primary) }, { - atWorktree: ['New Worktree', '[Folder] vscode', '[Worktrees] other (Current) *', '[Worktrees] feature'], - icons: ['worktree', 'folder', 'worktree', 'worktree'], - atUnlisted: ['New Worktree', '[Folder] vscode', '[Worktrees] other (Current) *', '[Worktrees] feature'], - atFolder: ['New Worktree', '[Folder] vscode (Current) *', '[Worktrees] feature', '[Worktrees] other'], + atWorktree: ['vscode', '[Worktrees] New Worktree', '[Worktrees] other (Current) *', '[Worktrees] feature'], + icons: ['folder', 'worktree', 'worktree', 'worktree'], + atUnlisted: ['vscode', '[Worktrees] New Worktree', '[Worktrees] other (Current) *', '[Worktrees] feature'], + atFolder: ['vscode (Current) *', '[Worktrees] New Worktree', '[Worktrees] feature', '[Worktrees] other'], }); }); @@ -116,7 +116,7 @@ suite('AgentHostWorktreeFolderChoices', () => { chip(unlisted.primaryAlias); await new Promise(resolve => setTimeout(resolve, 0)); assert.deepStrictEqual({ inNewWorktree, changes, folderChip, worktreeChip: chip(feature), beforeRead, unlistedWorktreeChip: chip(unlisted.worktree), elsewhereChip: chip(unlisted.elsewhere), otherRepositoryChip: chip(unlisted.otherRepository), primaryAliasChip: chip(unlisted.primaryAlias) }, { - inNewWorktree: { items: ['New Worktree *', '[Folder] vscode (Current)', '[Worktrees] feature', '[Worktrees] other'], chip: { icon: 'worktree', label: 'New Worktree', description: 'New worktree of vscode' } }, + inNewWorktree: { items: ['vscode (Current)', '[Worktrees] New Worktree *', '[Worktrees] feature', '[Worktrees] other'], chip: { icon: 'worktree', label: 'New Worktree', description: 'New worktree of vscode' } }, changes: ['isolation worktree', 'folder /work/vscode.worktrees/feature', 'isolation folder'], folderChip: { icon: 'folder', label: 'vscode', description: 'Folder: vscode' }, worktreeChip: { icon: 'worktree', label: 'feature', description: 'Worktree: feature' }, @@ -128,25 +128,26 @@ suite('AgentHostWorktreeFolderChoices', () => { }); }); - test('offers No Workspace last while the Chat view lists sessions, which the chip then shows by name, and picking a folder again works in it', async () => { + test('offers No Workspace first, before the folder, while the Chat view lists sessions, which the chip then shows by name, and picking a folder again works in it', async () => { const withoutSessionsList = await createChoices({ enabled: true, at: primary }); const { choices, actions, items, changes } = await createChoices({ enabled: true, at: primary, sessionsList: true }); const chip = () => { const { icon, label, description } = choices.getChip(primary); return { icon: icon.id, label, description }; }; await actions(primary).find(action => action.label === 'New Worktree')!.run(); await actions(primary).find(action => action.label === 'No Workspace')!.run(); - const noWorkspace = { items: items(primary), chip: chip(), icon: actions(primary).at(-1)?.icon?.id, separate: actions(primary).at(-1)?.category?.label === '' }; + const noWorkspace = { items: items(primary), chip: chip(), icon: actions(primary)[0]?.icon?.id, groups: actions(primary).map(action => action.category?.order) }; await actions(primary).find(action => action.label === 'vscode')!.run(); assert.deepStrictEqual({ withoutSessionsList: withoutSessionsList.items(primary), noWorkspace, folderAgain: { items: items(primary), chip: chip() }, changes }, { - withoutSessionsList: ['New Worktree', '[Folder] vscode (Current) *', '[Worktrees] feature', '[Worktrees] other'], + withoutSessionsList: ['vscode (Current) *', '[Worktrees] New Worktree', '[Worktrees] feature', '[Worktrees] other'], noWorkspace: { // Without a folder, no new worktree of it - items: ['[Folder] vscode (Current)', '[Worktrees] feature', '[Worktrees] other', 'No Workspace *'], + items: ['No Workspace *', 'vscode (Current)', '[Worktrees] feature', '[Worktrees] other'], chip: { icon: 'comment', label: 'No Workspace', description: 'No workspace: start the session in a temporary directory' }, icon: 'comment', - separate: true, + // No Workspace and the folder, then the worktrees + groups: [0, 0, 1, 1], }, - folderAgain: { items: ['[Folder] vscode (Current) *', '[Worktrees] feature', '[Worktrees] other', 'No Workspace'], chip: { icon: 'folder', label: 'vscode', description: 'Folder: vscode' } }, + folderAgain: { items: ['No Workspace', 'vscode (Current) *', '[Worktrees] feature', '[Worktrees] other'], chip: { icon: 'folder', label: 'vscode', description: 'Folder: vscode' } }, // No longer in a new worktree of the folder it no longer has changes: ['isolation worktree', 'no workspace', 'isolation folder', 'folder /work/vscode'], }); @@ -246,8 +247,8 @@ suite('AgentHostWorktreeFolderChoices', () => { await new Promise(resolve => setTimeout(resolve, 1000)); assert.deepStrictEqual({ before, after: items(primary) }, { - before: ['New Worktree', '[Folder] vscode (Current) *', '[Worktrees] feature'], - after: ['New Worktree', '[Folder] vscode (Current) *', '[Worktrees] feature', '[Worktrees] other'], + before: ['vscode (Current) *', '[Worktrees] New Worktree', '[Worktrees] feature'], + after: ['vscode (Current) *', '[Worktrees] New Worktree', '[Worktrees] feature', '[Worktrees] other'], }); })); }); From a439bfbf7da68e2f6bc5b9de31cd0f0f26423a1f Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 20:53:26 +0100 Subject: [PATCH 148/165] Sessions: an editor window's composer offers No Workspace and its folder first, then New Worktree and the worktrees under Worktrees Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/sessionWorkspacePicker.test.ts | 10 +++++----- .../browser/chat/sessionWorkspacePicker.ts | 18 ++++++++++-------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index a62152f6720816..0b9f64d879ceb1 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -8925,7 +8925,7 @@ suite('WorkspacePicker - Allowed folders', () => { }); }); - test('with sections for the window\'s folder and its worktrees, offers New Worktree first and each section\'s current folder first, described as current', async () => { + test('offers No Workspace and the window\'s folder first, then under Worktrees New Worktree first and the worktrees, with the current one first, described as current', async () => { const noWorkspace = { selected: false }; let current = '/local/vscode.worktrees/b'; const { picker } = createPicker(['/local/vscode.worktrees/b', '/local/vscode', '/local/vscode.worktrees/a'], noWorkspace, { @@ -8946,10 +8946,10 @@ suite('WorkspacePicker - Allowed folders', () => { const atFolder = items(); assert.deepStrictEqual({ atWorktree, ariaLabels, atFolder }, { - atWorktree: ['No Workspace', '---', 'New Worktree', '--- Folder', 'local/vscode', '--- Worktrees', 'b (Current) *', 'a (feature-a)'], + atWorktree: ['No Workspace', 'local/vscode', '--- Worktrees', 'New Worktree', 'b (Current) *', 'a (feature-a)'], // Screen readers hear where the window's folder is now, apart from the checked choice ariaLabels: [null, 'b, Current', null], - atFolder: ['No Workspace', '---', 'New Worktree', '--- Folder', 'local/vscode (Current) *', '--- Worktrees', 'b (feature-b)', 'a (feature-a)'], + atFolder: ['No Workspace', 'local/vscode (Current) *', '--- Worktrees', 'New Worktree', 'b (feature-b)', 'a (feature-a)'], }); }); @@ -9032,12 +9032,12 @@ suite('WorkspacePicker - Allowed folders', () => { assert.deepStrictEqual({ atFolder, inNewWorktree, atWorktree, fromWorktree: { chip: chip(), selected: newWorktree.selected } }, { atFolder: { - items: ['worktree New Worktree', 'folder local/vscode *', 'worktree a'], + items: ['folder local/vscode *', 'worktree New Worktree', 'worktree a'], chip: { icon: 'folder', label: 'local/vscode', ariaLabel: 'Folder: local/vscode', hover: 'Folder: local/vscode' }, }, inNewWorktree: { icon: 'worktree', label: 'New Worktree', ariaLabel: 'New worktree of local/vscode', hover: 'New worktree of local/vscode' }, atWorktree: { - items: ['worktree New Worktree', 'folder local/vscode', 'worktree a *'], + items: ['folder local/vscode', 'worktree New Worktree', 'worktree a *'], chip: { icon: 'worktree', label: 'a', ariaLabel: 'Worktree: a', hover: 'Worktree: a' }, }, fromWorktree: { chip: { icon: 'worktree', label: 'New Worktree', ariaLabel: 'New worktree of local/vscode', hover: 'New worktree of local/vscode' }, selected: true }, diff --git a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts index 6418010862d772..557e2eee495ffd 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts +++ b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts @@ -2347,7 +2347,7 @@ export class WorkspacePicker extends Disposable { } /** Puts the No Workspace choice before `items`, when the composer offers one. */ - private _withNoWorkspaceItem(items: IActionListItem[]): IActionListItem[] { + private _withNoWorkspaceItem(items: IActionListItem[], separate = true): IActionListItem[] { const noWorkspaceOption = this._getNoWorkspaceOption(); const hideNoWorkspaceOption = this._activeTriggerElement ? this._triggerOptions.get(this._activeTriggerElement)?.hideNoWorkspaceOption === true @@ -2378,9 +2378,9 @@ export class WorkspacePicker extends Disposable { ? { preserveVerticalPosition: true, alignToAnchorTop: true } : undefined, }; - return items.length > 0 + return items.length > 0 && separate ? [noWorkspace, { kind: ActionListItemKind.Separator, label: '' }, ...items] - : [noWorkspace]; + : [noWorkspace, ...items]; } protected _getNoWorkspaceOption(): IWorkspacePickerNoWorkspaceOption | undefined { @@ -2886,7 +2886,7 @@ export class WorkspacePicker extends Disposable { /** * The items of a picker restricted to its allowed folders ({@link IWorkspacePickerOptions.allowedFolders}): No Workspace, - * the folders and New Worktree, with no recent workspace and no browse action. Undefined for a picker that is not. + * the folders, New Worktree and the worktrees, with no recent workspace and no browse action. Undefined for a picker that is not. */ protected _buildRestrictedItems(): IActionListItem[] | undefined { const allowedFolders = this._getAllowedFolders(); @@ -2899,11 +2899,13 @@ export class WorkspacePicker extends Disposable { return this._withSelectionMarks(this._withNewWorktreeItem(this._withNoWorkspaceItem(folders.map(folder => folder.item)))); } - // New Worktree first, then the sections that every picker of a window's folders and their worktrees shows + // No Workspace and the folder first; then, under Worktrees, New Worktree first and the worktrees + const sections = groupFolderLocations(folders.map(folder => ({ item: folder.item, kind: folder.kind ?? 'folder', current: folder.current }))); + const items = this._withNoWorkspaceItem(sections.filter(section => section.kind !== 'worktree').flatMap(section => section.items), false); const newWorktreeItem = this._getNewWorktreeItem(); - const items = this._withNoWorkspaceItem(newWorktreeItem ? [newWorktreeItem] : []); - for (const section of groupFolderLocations(folders.map(folder => ({ item: folder.item, kind: folder.kind ?? 'folder', current: folder.current })))) { - items.push({ kind: ActionListItemKind.Separator, label: section.title }, ...section.items); + const worktrees = sections.find(section => section.kind === 'worktree'); + if (newWorktreeItem || worktrees) { + items.push({ kind: ActionListItemKind.Separator, label: worktrees?.title ?? localize('workspacePicker.worktrees', "Worktrees") }, ...(newWorktreeItem ? [newWorktreeItem] : []), ...(worktrees?.items ?? [])); } return this._withSelectionMarks(items); } From acf5dd8b9d4a47f4f3c3647aa23f29a24529a6b2 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 21:22:43 +0100 Subject: [PATCH 149/165] Worktree infra: what a folder picker's folder and New Worktree do, to show below their names Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workspaces/common/folderLocationSections.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/vs/workbench/services/workspaces/common/folderLocationSections.ts b/src/vs/workbench/services/workspaces/common/folderLocationSections.ts index 80b0c34923304c..1f526f95068909 100644 --- a/src/vs/workbench/services/workspaces/common/folderLocationSections.ts +++ b/src/vs/workbench/services/workspaces/common/folderLocationSections.ts @@ -50,3 +50,13 @@ export function getCurrentFolderLocationDescription(): string { export function getNewWorktreeLabel(): string { return localize('folderLocations.newWorktree', "New Worktree"); } + +/** What the window's folder does as a choice of a picker of {@link groupFolderLocations}, shown below its name. */ +export function getFolderLocationDetail(): string { + return localize('folderLocations.folderDetail', "Works in the folder already on this computer"); +} + +/** What New Worktree does, shown below its name, as the Agents window's isolation picker describes it. */ +export function getNewWorktreeDetail(): string { + return localize('folderLocations.newWorktreeDetail', "Creates a separate copy for this session"); +} From d71a6881da27fb2310d1d43cb8cdeec64cdc887a Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 21:24:14 +0100 Subject: [PATCH 150/165] Worktree support: the Chat view's folder picker shows what No Workspace, the folder and New Worktree do below their names, first, with the worktrees after Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostWorktreeFolderChoices.ts | 9 +++++--- .../agentHostWorktreeFolderChoices.test.ts | 23 +++++++++++-------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts index bcb567bc8499da..72482a2fde743d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.ts @@ -21,7 +21,7 @@ import { IActionWidgetDropdownAction } from '../../../../../../platform/actionWi import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IFolderLocation, IWorkspaceFolderLocationService } from '../../../../../../platform/workspace/common/folderLocations.js'; import { IWorkspaceContextService, WorkbenchState } from '../../../../../../platform/workspace/common/workspace.js'; -import { getCurrentFolderLocationDescription, getNewWorktreeLabel, groupFolderLocations } from '../../../../../services/workspaces/common/folderLocationSections.js'; +import { getCurrentFolderLocationDescription, getFolderLocationDetail, getNewWorktreeDetail, getNewWorktreeLabel, groupFolderLocations } from '../../../../../services/workspaces/common/folderLocationSections.js'; import { ChatConfiguration } from '../../../common/constants.js'; import { IAgentSessionsService } from '../agentSessionsService.js'; import { IAgentHostNewSessionFolderService } from './agentHostNewSessionFolderService.js'; @@ -171,7 +171,7 @@ export class AgentHostWorktreeFolderChoices extends Disposable { getActions(action: MenuItemAction, selected: URI | undefined, onDidPick: () => void): IActionWidgetDropdownAction[] { const noWorkspaceSelected = this.isNoWorkspaceSelected(); const newWorktreeSelected = !noWorkspaceSelected && this.isNewWorktreeSelected(); - // The folder, and what has none, first; then the worktrees, a new one first + // What has no folder, the folder and a new worktree of it first, each with what it does below its name; then the worktrees const workspaceGroup = { label: '', order: 0 }; const worktreesGroup = { label: localize('agentHost.folderPicker.worktrees', "Worktrees"), order: 1, showHeader: true }; let newWorktree: IActionWidgetDropdownAction | undefined; @@ -181,11 +181,12 @@ export class AgentHostWorktreeFolderChoices extends Disposable { ...action, id: 'agentHostFolder.newWorktree', label: getNewWorktreeLabel(), + detail: getNewWorktreeDetail(), tooltip: localize('agentHost.folderPicker.newWorktreeDescription', "Start the session in a new worktree of the selected folder."), checked: newWorktreeSelected, icon: WORKTREE_ICON, enabled: true, - category: worktreesGroup, + category: workspaceGroup, run: async () => { await this._setIsolation(true); onDidPick(); @@ -200,6 +201,7 @@ export class AgentHostWorktreeFolderChoices extends Disposable { ...action, id: `agentHostFolder.${choice.uri.toString()}`, label: choice.label, + detail: section.kind === 'worktree' ? undefined : getFolderLocationDetail(), description: choice.current ? getCurrentFolderLocationDescription() : undefined, checked: !noWorkspaceSelected && !newWorktreeSelected && !!selected && extUriBiasedIgnorePathCase.isEqual(selected, choice.uri), icon: choice.kind === 'worktree' ? WORKTREE_ICON : Codicon.folder, @@ -225,6 +227,7 @@ export class AgentHostWorktreeFolderChoices extends Disposable { ...action, id: 'agentHostFolder.noWorkspace', label: getNoWorkspaceLabel(), + detail: localize('agentHost.folderPicker.noWorkspaceDescription', "Start without a backing workspace"), tooltip: localize('agentHost.folderPicker.noWorkspaceDescription', "Start without a backing workspace"), checked: noWorkspaceSelected, icon: NO_WORKSPACE_ICON, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts index 44fb48404d78bf..2a133664269bcd 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeFolderChoices.test.ts @@ -84,17 +84,19 @@ suite('AgentHostWorktreeFolderChoices', () => { return { choices, actions, items, changes, configDirectories, listed, onDidChangeSessions }; } - test('offers the folder, then under Worktrees New Worktree first and the worktrees, each with where the folder is now first', async () => { + test('offers the folder and New Worktree, each with what it does, then under Worktrees the worktrees, with where the folder is now first', async () => { const atWorktree = await createChoices({ enabled: true, at: other }); const atFolder = await createChoices({ enabled: true, at: primary }); // A worktree that no session lists any more, such as one whose sessions are archived const atUnlisted = await createChoices({ enabled: true, at: other, listed: [feature] }); - assert.deepStrictEqual({ atWorktree: atWorktree.items(other), icons: atWorktree.actions(other).map(action => action.icon?.id), atUnlisted: atUnlisted.items(other), atFolder: atFolder.items(primary) }, { - atWorktree: ['vscode', '[Worktrees] New Worktree', '[Worktrees] other (Current) *', '[Worktrees] feature'], + assert.deepStrictEqual({ atWorktree: atWorktree.items(other), details: atFolder.actions(primary).map(action => action.detail ?? null), icons: atWorktree.actions(other).map(action => action.icon?.id), atUnlisted: atUnlisted.items(other), atFolder: atFolder.items(primary) }, { + atWorktree: ['vscode', 'New Worktree', '[Worktrees] other (Current) *', '[Worktrees] feature'], + // Below their names, what the folder and New Worktree do; the worktrees as they are + details: ['Works in the folder already on this computer', 'Creates a separate copy for this session', null, null], icons: ['folder', 'worktree', 'worktree', 'worktree'], - atUnlisted: ['vscode', '[Worktrees] New Worktree', '[Worktrees] other (Current) *', '[Worktrees] feature'], - atFolder: ['vscode (Current) *', '[Worktrees] New Worktree', '[Worktrees] feature', '[Worktrees] other'], + atUnlisted: ['vscode', 'New Worktree', '[Worktrees] other (Current) *', '[Worktrees] feature'], + atFolder: ['vscode (Current) *', 'New Worktree', '[Worktrees] feature', '[Worktrees] other'], }); }); @@ -116,7 +118,7 @@ suite('AgentHostWorktreeFolderChoices', () => { chip(unlisted.primaryAlias); await new Promise(resolve => setTimeout(resolve, 0)); assert.deepStrictEqual({ inNewWorktree, changes, folderChip, worktreeChip: chip(feature), beforeRead, unlistedWorktreeChip: chip(unlisted.worktree), elsewhereChip: chip(unlisted.elsewhere), otherRepositoryChip: chip(unlisted.otherRepository), primaryAliasChip: chip(unlisted.primaryAlias) }, { - inNewWorktree: { items: ['vscode (Current)', '[Worktrees] New Worktree *', '[Worktrees] feature', '[Worktrees] other'], chip: { icon: 'worktree', label: 'New Worktree', description: 'New worktree of vscode' } }, + inNewWorktree: { items: ['vscode (Current)', 'New Worktree *', '[Worktrees] feature', '[Worktrees] other'], chip: { icon: 'worktree', label: 'New Worktree', description: 'New worktree of vscode' } }, changes: ['isolation worktree', 'folder /work/vscode.worktrees/feature', 'isolation folder'], folderChip: { icon: 'folder', label: 'vscode', description: 'Folder: vscode' }, worktreeChip: { icon: 'worktree', label: 'feature', description: 'Worktree: feature' }, @@ -134,17 +136,18 @@ suite('AgentHostWorktreeFolderChoices', () => { const chip = () => { const { icon, label, description } = choices.getChip(primary); return { icon: icon.id, label, description }; }; await actions(primary).find(action => action.label === 'New Worktree')!.run(); await actions(primary).find(action => action.label === 'No Workspace')!.run(); - const noWorkspace = { items: items(primary), chip: chip(), icon: actions(primary)[0]?.icon?.id, groups: actions(primary).map(action => action.category?.order) }; + const noWorkspace = { items: items(primary), chip: chip(), icon: actions(primary)[0]?.icon?.id, detail: actions(primary)[0]?.detail, groups: actions(primary).map(action => action.category?.order) }; await actions(primary).find(action => action.label === 'vscode')!.run(); assert.deepStrictEqual({ withoutSessionsList: withoutSessionsList.items(primary), noWorkspace, folderAgain: { items: items(primary), chip: chip() }, changes }, { - withoutSessionsList: ['vscode (Current) *', '[Worktrees] New Worktree', '[Worktrees] feature', '[Worktrees] other'], + withoutSessionsList: ['vscode (Current) *', 'New Worktree', '[Worktrees] feature', '[Worktrees] other'], noWorkspace: { // Without a folder, no new worktree of it items: ['No Workspace *', 'vscode (Current)', '[Worktrees] feature', '[Worktrees] other'], chip: { icon: 'comment', label: 'No Workspace', description: 'No workspace: start the session in a temporary directory' }, icon: 'comment', // No Workspace and the folder, then the worktrees + detail: 'Start without a backing workspace', groups: [0, 0, 1, 1], }, folderAgain: { items: ['No Workspace', 'vscode (Current) *', '[Worktrees] feature', '[Worktrees] other'], chip: { icon: 'folder', label: 'vscode', description: 'Folder: vscode' } }, @@ -247,8 +250,8 @@ suite('AgentHostWorktreeFolderChoices', () => { await new Promise(resolve => setTimeout(resolve, 1000)); assert.deepStrictEqual({ before, after: items(primary) }, { - before: ['vscode (Current) *', '[Worktrees] New Worktree', '[Worktrees] feature'], - after: ['vscode (Current) *', '[Worktrees] New Worktree', '[Worktrees] feature', '[Worktrees] other'], + before: ['vscode (Current) *', 'New Worktree', '[Worktrees] feature'], + after: ['vscode (Current) *', 'New Worktree', '[Worktrees] feature', '[Worktrees] other'], }); })); }); From 41350a8d1e10772a1d38a59ed75e43a9bd56dee5 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 21:25:39 +0100 Subject: [PATCH 151/165] Sessions: an editor window's composer shows what No Workspace, its folder and New Worktree do below their names, first, without a check mark on the selected choice Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/sessionWorkspacePicker.test.ts | 19 ++++++++----- .../browser/chat/sessionWorkspacePicker.ts | 27 +++++++++++-------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index 0b9f64d879ceb1..dae3f41d6ab48b 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -8925,7 +8925,7 @@ suite('WorkspacePicker - Allowed folders', () => { }); }); - test('offers No Workspace and the window\'s folder first, then under Worktrees New Worktree first and the worktrees, with the current one first, described as current', async () => { + test('offers No Workspace, the window\'s folder and New Worktree first, each with what it does below its name and without a check mark, then under Worktrees the worktrees, with the current one first, described as current', async () => { const noWorkspace = { selected: false }; let current = '/local/vscode.worktrees/b'; const { picker } = createPicker(['/local/vscode.worktrees/b', '/local/vscode', '/local/vscode.worktrees/a'], noWorkspace, { @@ -8944,12 +8944,17 @@ suite('WorkspacePicker - Allowed folders', () => { current = '/local/vscode'; await picker.dispatchFolder(URI.file('/local/vscode'), 'local-1'); const atFolder = items(); + const actions = picker.getItems().filter(item => item.kind === ActionListItemKind.Action); + const details = actions.map(item => item.detail ?? null); + const checkMarks = actions.filter(item => item.group?.icon?.id === Codicon.check.id).length; - assert.deepStrictEqual({ atWorktree, ariaLabels, atFolder }, { - atWorktree: ['No Workspace', 'local/vscode', '--- Worktrees', 'New Worktree', 'b (Current) *', 'a (feature-a)'], + assert.deepStrictEqual({ atWorktree, ariaLabels, atFolder, details, checkMarks }, { + atWorktree: ['No Workspace', 'local/vscode', 'New Worktree', '--- Worktrees', 'b (Current) *', 'a (feature-a)'], // Screen readers hear where the window's folder is now, apart from the checked choice ariaLabels: [null, 'b, Current', null], - atFolder: ['No Workspace', 'local/vscode (Current) *', '--- Worktrees', 'New Worktree', 'b (feature-b)', 'a (feature-a)'], + atFolder: ['No Workspace', 'local/vscode (Current) *', 'New Worktree', '--- Worktrees', 'b (feature-b)', 'a (feature-a)'], + details: ['Start without a backing workspace', 'Works in the folder already on this computer', 'Creates a separate copy for this session', null, null], + checkMarks: 0, }); }); @@ -8993,11 +8998,11 @@ suite('WorkspacePicker - Allowed folders', () => { const trigger = container.querySelector('.sessions-workspace-picker-trigger [role="button"]')!; assert.deepStrictEqual({ - // Its selected choice is checked, in front of the choice's own icon + // By the choice's own icon, without a check mark on the selected one: the trigger shows it items: picker.getItems().map(item => `${item.group?.icon?.id} ${item.labelIcon?.id} ${item.label}${item.description ? ` (${item.description})` : ''}`), trigger: { label: trigger.getAttribute('aria-label'), worktreeIcon: !!trigger.querySelector(`.${ThemeIcon.asClassNameArray(Codicon.gitBranchCheckout).join('.')}`) }, }, { - items: ['check git-branch-checkout a (feature-a)', 'blank folder local/vscode'], + items: ['git-branch-checkout undefined a (feature-a)', 'folder undefined local/vscode'], trigger: { label: 'Workspace: a', worktreeIcon: true }, }); }); @@ -9021,7 +9026,7 @@ suite('WorkspacePicker - Allowed folders', () => { ariaLabel: trigger.getAttribute('aria-label'), hover: picker.getSelectedLocationDescription() ?? null, }); - const items = () => picker.getItems().filter(item => item.kind !== ActionListItemKind.Separator).map(item => `${item.labelIcon?.id} ${item.label}${item.item?.checked ? ' *' : ''}`); + const items = () => picker.getItems().filter(item => item.kind !== ActionListItemKind.Separator).map(item => `${item.group?.icon?.id} ${item.label}${item.item?.checked ? ' *' : ''}`); const atFolder = { items: items(), chip: chip() }; await picker.dispatchItem(picker.getItems().find(item => item.label === 'New Worktree')!.item!); diff --git a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts index 557e2eee495ffd..1d241b6c1afba2 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts +++ b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts @@ -41,7 +41,7 @@ import { IProgressService, ProgressLocation } from '../../../../../platform/prog import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { IFolderLocation } from '../../../../../platform/workspace/common/folderLocations.js'; -import { FolderLocationKind, getCurrentFolderLocationDescription, getNewWorktreeLabel, groupFolderLocations } from '../../../../services/workspaces/common/folderLocationSections.js'; +import { FolderLocationKind, getCurrentFolderLocationDescription, getFolderLocationDetail, getNewWorktreeDetail, getNewWorktreeLabel, groupFolderLocations } from '../../../../services/workspaces/common/folderLocationSections.js'; import { IGitHubInfo, isActiveSessionStatus, ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_GITHUB, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../../services/sessions/common/session.js'; import { ISessionsProvidersService } from '../sessionsProvidersService.js'; import { cloneRepository } from '../cloneRepository.js'; @@ -64,7 +64,7 @@ import { type IResolvedFolderWorkspace, SessionWorkspaceFallback } from './sessi import { IChatRequestVariableEntry } from '../../../chat/common/attachments/chatVariableEntries.js'; import { ADDITIONAL_FOLDER_CONTEXT_ID_PREFIX, ADDITIONAL_REPOSITORY_CONTEXT_ID_PREFIX, getAdditionalFolderContextId, getAdditionalRepositoryContextId } from '../../common/newChatContextIds.js'; import { registerPickerKeybindingPresentation } from './newChatPickerKeybinding.js'; -import { getNoWorkspaceChoicePresentation } from './noWorkspaceChoice.js'; +import { getNoWorkspaceChoicePresentation, getNoWorkspaceDescription } from './noWorkspaceChoice.js'; import { createSessionPickerToolbar } from './sessionPickerToolbar.js'; import { getEnvironmentWorkspaceProvider, ISessionCreationEnvironment, resolveEnvironmentWorkspace } from './sessionEnvironmentPicker.js'; @@ -2301,11 +2301,11 @@ export class WorkspacePicker extends Disposable { * Marks the selected choice with a check in front of each choice's own icon, as a picker of a few choices does, such as * one restricted to a window's folders. */ - private _withSelectionMarks(items: IActionListItem[]): IActionListItem[] { + /** Shows the items of a picker restricted to its allowed folders by their own icons, without a check mark on the selected one: its trigger shows which it is. */ + private _withItemIcons(items: IActionListItem[]): IActionListItem[] { return items.map(item => item.kind === ActionListItemKind.Action ? { ...item, - group: { ...item.group, title: item.group?.title ?? '', icon: item.item?.checked ? Codicon.check : Codicon.blank }, - labelIcon: item.group?.icon, + group: { ...item.group, title: item.group?.title ?? '', icon: item.group?.icon ?? Codicon.blank }, } : item); } @@ -2896,18 +2896,23 @@ export class WorkspacePicker extends Disposable { this._resolveSelectedAllowedFolder(); const folders = this._buildAllowedFolderItems(allowedFolders); if (!folders.some(folder => folder.kind)) { - return this._withSelectionMarks(this._withNewWorktreeItem(this._withNoWorkspaceItem(folders.map(folder => folder.item)))); + return this._withItemIcons(this._withNewWorktreeItem(this._withNoWorkspaceItem(folders.map(folder => folder.item)))); } - // No Workspace and the folder first; then, under Worktrees, New Worktree first and the worktrees + // No Workspace, the folder and New Worktree first, each with what it does below its name, as the Agents window's + // isolation picker shows them; then the worktrees under Worktrees const sections = groupFolderLocations(folders.map(folder => ({ item: folder.item, kind: folder.kind ?? 'folder', current: folder.current }))); - const items = this._withNoWorkspaceItem(sections.filter(section => section.kind !== 'worktree').flatMap(section => section.items), false); + const items = this._withNoWorkspaceItem(sections.filter(section => section.kind !== 'worktree').flatMap(section => section.items).map(item => ({ ...item, detail: getFolderLocationDetail() })), false) + .map(item => item.item?.id === 'workspacePicker.chat' ? { ...item, detail: getNoWorkspaceDescription() } : item); const newWorktreeItem = this._getNewWorktreeItem(); + if (newWorktreeItem) { + items.push({ ...newWorktreeItem, detail: getNewWorktreeDetail() }); + } const worktrees = sections.find(section => section.kind === 'worktree'); - if (newWorktreeItem || worktrees) { - items.push({ kind: ActionListItemKind.Separator, label: worktrees?.title ?? localize('workspacePicker.worktrees', "Worktrees") }, ...(newWorktreeItem ? [newWorktreeItem] : []), ...(worktrees?.items ?? [])); + if (worktrees) { + items.push({ kind: ActionListItemKind.Separator, label: worktrees.title }, ...worktrees.items); } - return this._withSelectionMarks(items); + return this._withItemIcons(items); } /** The folders the picker is restricted to; none restrict it. */ From 6aa8ba049ebac1c37ad7020b2afe1800876adbae Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 21:32:54 +0100 Subject: [PATCH 152/165] Action list: a row with a description and a detail keeps its detail inside it Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../actionWidget/browser/actionWidget.css | 4 ++- .../test/browser/actionList.test.ts | 36 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index 81c5a46d8e1e9f..9999cc5eb1694d 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -375,7 +375,9 @@ align-content: center; padding-right: 6px; - .title { + /* The list sets the row's line height to its height: the first line keeps to its text, so the detail fits below it */ + .title, + .description { line-height: 14px; } } diff --git a/src/vs/platform/actionWidget/test/browser/actionList.test.ts b/src/vs/platform/actionWidget/test/browser/actionList.test.ts index 201bfa86246018..23514ec57be42e 100644 --- a/src/vs/platform/actionWidget/test/browser/actionList.test.ts +++ b/src/vs/platform/actionWidget/test/browser/actionList.test.ts @@ -352,6 +352,42 @@ suite('ActionListWidget', () => { }); }); + test('a row with a description and a detail keeps its detail inside it, as one without a description does, also with descriptions inline', () => { + const layoutOf = (inlineDescription: boolean) => { + const widget = createActionListWidget(disposables, { + items: [{ ...action('without'), detail: 'Start without a backing workspace' }, { ...action('with'), description: '~/work', detail: 'Works in the folder already on this computer' }, action('next')], + listOptions: { showFilter: true, inlineDescription }, + }); + const wrapper = document.createElement('div'); + wrapper.classList.add('action-widget'); + widget.domNode.parentElement?.insertBefore(wrapper, widget.domNode); + wrapper.appendChild(widget.domNode); + disposables.add({ dispose: () => wrapper.remove() }); + widget.layout(600, 400); + const rows = [...widget.domNode.querySelectorAll('.monaco-list-row.action')]; + const offsets = (row: HTMLElement) => { + const bounds = row.getBoundingClientRect(); + const detail = row.querySelector('.detail')!.getBoundingClientRect(); + const title = row.querySelector('.title')!.getBoundingClientRect(); + return { title: title.top - bounds.top, detail: detail.top - bounds.top, detailInside: detail.bottom <= bounds.bottom }; + }; + return { + without: offsets(rows[0]), + with: offsets(rows[1]), + noOverlap: rows[2].getBoundingClientRect().top >= rows[1].querySelector('.detail')!.getBoundingClientRect().bottom, + }; + }; + const result = { stacked: layoutOf(false), inline: layoutOf(true) }; + + assert.deepStrictEqual({ + stacked: { detailInside: result.stacked.with.detailInside, sameAsWithout: result.stacked.with.detail === result.stacked.without.detail && result.stacked.with.title === result.stacked.without.title, noOverlap: result.stacked.noOverlap }, + inline: { detailInside: result.inline.with.detailInside, sameAsWithout: result.inline.with.detail === result.inline.without.detail && result.inline.with.title === result.inline.without.title, noOverlap: result.inline.noOverlap }, + }, { + stacked: { detailInside: true, sameAsWithout: true, noOverlap: true }, + inline: { detailInside: true, sameAsWithout: true, noOverlap: true }, + }); + }); + test('wrapped details resize rows without clipping or overlapping subsequent items', () => { const widget = createActionListWidget(disposables, { items: [{ ...action('Remote environment'), detail: 'Access agents on this machine from VS Code, vscode.dev/agents, GitHub apps, or github.com/copilot. '.repeat(3) }, action('Next')], From a82a018495096d833898f917bb9815afa9125a25 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 22:04:24 +0100 Subject: [PATCH 153/165] Action list: a row with a detail is laid out alike with descriptions inline, its detail as far below its label Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../actionWidget/browser/actionWidget.css | 4 ++-- .../test/browser/actionList.test.ts | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index 9999cc5eb1694d..6425f8193c9c8f 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -449,8 +449,8 @@ /* Inline description mode — description rendered right after the label */ .action-widget .inline-description .monaco-list-row.action { - /* Override the row gap so group-title and toolbar sit flush */ - gap: 0; + /* Override the gap between items so group-title and toolbar sit flush; a detail keeps the gap above it */ + column-gap: 0; .title { flex: 0 1 auto; diff --git a/src/vs/platform/actionWidget/test/browser/actionList.test.ts b/src/vs/platform/actionWidget/test/browser/actionList.test.ts index 23514ec57be42e..20497dbe09acda 100644 --- a/src/vs/platform/actionWidget/test/browser/actionList.test.ts +++ b/src/vs/platform/actionWidget/test/browser/actionList.test.ts @@ -388,6 +388,27 @@ suite('ActionListWidget', () => { }); }); + test('a row with a detail is laid out alike with descriptions inline or not: its label, and its detail below it as far apart', () => { + const offsets = (inlineDescription: boolean) => { + const widget = createActionListWidget(disposables, { + items: [{ ...action('New Worktree'), detail: 'Creates a separate copy for this session', group: { title: '', icon: Codicon.worktree } }], + listOptions: { showFilter: false, inlineDescription }, + }); + const wrapper = document.createElement('div'); + wrapper.classList.add('action-widget'); + widget.domNode.parentElement?.insertBefore(wrapper, widget.domNode); + wrapper.appendChild(widget.domNode); + disposables.add({ dispose: () => wrapper.remove() }); + widget.layout(600, 200); + const row = widget.domNode.querySelector('.monaco-list-row.action')!; + const bounds = row.getBoundingClientRect(); + const of = (selector: string) => { const b = row.querySelector(selector)!.getBoundingClientRect(); return { left: b.left - bounds.left, top: b.top - bounds.top }; }; + return { height: bounds.height, title: of('.title'), detail: of('.detail') }; + }; + + assert.deepStrictEqual(offsets(true), offsets(false)); + }); + test('wrapped details resize rows without clipping or overlapping subsequent items', () => { const widget = createActionListWidget(disposables, { items: [{ ...action('Remote environment'), detail: 'Access agents on this machine from VS Code, vscode.dev/agents, GitHub apps, or github.com/copilot. '.repeat(3) }, action('Next')], From 665988596d9b799e27d47b0fac1f7cb710ce1b78 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 22:06:52 +0100 Subject: [PATCH 154/165] Sessions: an editor window's composer lays out its choices with details as the Agents window's isolation picker does Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/sessionWorkspacePicker.test.ts | 57 ++++++++++++++++++- .../browser/chat/media/chatWidget.css | 3 +- .../browser/chat/sessionWorkspacePicker.ts | 14 ++++- 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index dae3f41d6ab48b..0c1fe6164efe4e 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -24,7 +24,11 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; -import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListOptions } from '../../../../../platform/actionWidget/browser/actionList.js'; +import { ActionListItemKind, ActionListWidget, IActionListDelegate, IActionListItem, IActionListOptions } from '../../../../../platform/actionWidget/browser/actionList.js'; +import { mainWindow } from '../../../../../base/browser/window.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { NullOpenerService } from '../../../../../platform/opener/test/common/nullOpenerService.js'; +import '../../../../../base/browser/ui/codicons/codiconStyles.js'; import { RemoteAgentHostConnectionStatus, IRemoteAgentHostConnectionInfo, IRemoteAgentHostService, RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { TUNNEL_ADDRESS_PREFIX } from '../../../../../platform/agentHost/common/tunnelAgentHost.js'; import { cloudSandboxAddress } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; @@ -8815,7 +8819,8 @@ suite('WorkspacePicker - Allowed folders', () => { providersService.setProviders([{ ...provider, resolveWorkspace: (folderUri: URI) => options?.resolves && !options.resolves(folderUri) ? undefined : resolveWorkspace(folderUri) }]); const allowedFolders = observableValue('allowedFolders', folders.map(folder => URI.file(folder))); let shows = 0; - const actionWidgetService = upcastPartial({ isVisible: false, hide: () => { }, show: () => { shows++; }, updateItems: () => { } }); + const shown: { listOptions?: IActionListOptions } = {}; + const actionWidgetService = upcastPartial({ isVisible: false, hide: () => { }, show: (...args: unknown[]) => { shows++; shown.listOptions = args[8] as IActionListOptions | undefined; }, updateItems: () => { } }); const getNoWorkspaceOption = noWorkspace && (() => ({ description: 'No workspace', isSelected: noWorkspace.selected, select: () => { noWorkspace.selected = true; } })); const picker = createTestPicker(store, providersService, undefined, undefined, DispatchingWorkspacePicker, undefined, undefined, undefined, { restoreFromSessions: false, allowedFolders, getNoWorkspaceOption, ...options }, undefined, actionWidgetService) as DispatchingWorkspacePicker; const container = document.createElement('div'); @@ -8830,7 +8835,7 @@ suite('WorkspacePicker - Allowed folders', () => { ariaLabel: trigger.getAttribute('aria-label'), }; }; - return { picker, allowedFolders, snapshot }; + return { picker, allowedFolders, snapshot, shown }; } test('offers only the window\'s folders: read-only for one folder, such as a worktree, and a choice for several', () => { @@ -8925,6 +8930,52 @@ suite('WorkspacePicker - Allowed folders', () => { }); }); + test('lays out its choices with details as the Agents window\'s isolation picker does, and its worktrees aligned with them', async () => { + const { picker, shown } = createPicker(['/local/vscode.worktrees/b', '/local/vscode'], { selected: false }, { + getAllowedFolderPresentation: folderUri => folderUri.path === '/local/vscode' + ? { kind: 'folder', current: true } + : { label: 'b', description: 'feature-b', icon: Codicon.gitBranchCheckout, kind: 'worktree', current: false }, + newWorktree: { isAvailable: () => true, isSelected: () => false, setSelected: () => { } }, + }); + picker.setSelectedWorkspace(URI.file('/local/vscode'), { fireEvent: false }); + picker.showPicker(); + const render = (items: readonly IActionListItem[], listOptions: IActionListOptions | undefined) => { + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.set(IKeybindingService, new MockKeybindingService()); + instantiationService.set(IHoverService, NullHoverService); + instantiationService.set(IOpenerService, NullOpenerService); + const widget = store.add(instantiationService.createInstance(ActionListWidget, 'layout', false, items, { onHide: () => { }, onSelect: () => { } }, undefined, listOptions)); + const wrapper = mainWindow.document.body.appendChild(document.createElement('div')); + wrapper.classList.add('action-widget'); + wrapper.appendChild(widget.domNode); + store.add({ dispose: () => wrapper.remove() }); + widget.layout(400, 400); + return [...widget.domNode.querySelectorAll('.monaco-list-row.action')].map(row => { + const bounds = row.getBoundingClientRect(); + const of = (selector: string) => { + const element = row.querySelector(selector); + const b = element && mainWindow.getComputedStyle(element).display !== 'none' ? element.getBoundingClientRect() : undefined; + return b && { left: b.left - bounds.left, top: b.top - bounds.top }; + }; + const icon = row.querySelector('.codicon'); + return { height: bounds.height, iconSize: icon && mainWindow.getComputedStyle(icon).fontSize, title: of('.title'), detail: of('.detail') }; + }); + }; + // As the isolation picker shows its choices: by their icons, with no list options of its own + const [isolation] = render([{ kind: ActionListItemKind.Action, label: 'New Worktree', detail: 'Creates a separate copy for this session', group: { title: '', icon: Codicon.worktree }, item: {} }], undefined); + const rows = render(picker.getItems(), shown.listOptions); + const twoLine = rows.filter(row => row.detail); + const worktree = rows.find(row => !row.detail)!; + + assert.deepStrictEqual({ + twoLine: twoLine.map(row => ({ ...row, title: { left: row.title?.left, top: row.title?.top } })), + worktree: { iconSize: worktree.iconSize, titleLeft: worktree.title?.left }, + }, { + twoLine: [isolation, isolation, isolation].map(row => ({ ...row, title: { left: row.title?.left, top: row.title?.top } })), + worktree: { iconSize: isolation.iconSize, titleLeft: isolation.title?.left }, + }); + }); + test('offers No Workspace, the window\'s folder and New Worktree first, each with what it does below its name and without a check mark, then under Worktrees the worktrees, with the current one first, described as current', async () => { const noWorkspace = { selected: false }; let current = '/local/vscode.worktrees/b'; diff --git a/src/vs/workbench/contrib/sessions/browser/chat/media/chatWidget.css b/src/vs/workbench/contrib/sessions/browser/chat/media/chatWidget.css index 68f14f4399a5fd..7bf434f1b65895 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/media/chatWidget.css +++ b/src/vs/workbench/contrib/sessions/browser/chat/media/chatWidget.css @@ -904,7 +904,8 @@ outline-offset: var(--vscode-spacing-sizeNone); } -.action-widget .sessions-new-chat-picker-list .monaco-list-row.action .codicon { +/* A picker restricted to a window's folders keeps the action list's icons, which its choices' details align with. */ +.action-widget .sessions-new-chat-picker-list:not(.sessions-restricted-workspace-picker-list) .monaco-list-row.action .codicon { font-size: var(--vscode-codiconFontSize); } diff --git a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts index 1d241b6c1afba2..bf74ae64a04c80 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts +++ b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts @@ -98,6 +98,15 @@ export const WORKSPACE_PICKER_LIST_OPTIONS = { hideDefaultKeybindingTooltip: true, } as const satisfies IActionListOptions; +/** + * How the rows of a picker restricted to a window's folders are laid out: as the Agents window's isolation picker lays out + * its choices, by the action list's own icons, which its details align with. + */ +export const RESTRICTED_WORKSPACE_PICKER_LIST_OPTIONS = { + ...WORKSPACE_PICKER_LIST_OPTIONS, + className: `${WORKSPACE_PICKER_LIST_OPTIONS.className} sessions-restricted-workspace-picker-list`, +} as const satisfies IActionListOptions; + /** * Grace period for a restored remote workspace's provider to reach Connected * before we fall back to no selection. SSH tunnels typically connect within @@ -970,12 +979,13 @@ export class WorkspacePicker extends Disposable { } protected _buildListOptions(items: readonly IActionListItem[], pickerWidth: number | undefined): IActionListOptions { + const options = this._getAllowedFolders() ? RESTRICTED_WORKSPACE_PICKER_LIST_OPTIONS : WORKSPACE_PICKER_LIST_OPTIONS; const isConsolidatedWorkspacePicker = this._directPickerAttachesContext !== true; const showFilter = isConsolidatedWorkspacePicker || items.filter(i => i.kind === ActionListItemKind.Action).length > FILTER_THRESHOLD; return showFilter ? { - ...WORKSPACE_PICKER_LIST_OPTIONS, + ...options, showFilter: true, focusFilterOnOpen: isConsolidatedWorkspacePicker, filterPlaceholder: isConsolidatedWorkspacePicker ? localize('workspacePicker.filter', "Search") : undefined, @@ -988,7 +998,7 @@ export class WorkspacePicker extends Disposable { this._refreshPickerItems(); }, } - : { ...WORKSPACE_PICKER_LIST_OPTIONS, submenuHoverDelay: isConsolidatedWorkspacePicker ? 0 : undefined, minWidth: pickerWidth, maxWidth: pickerWidth }; + : { ...options, submenuHoverDelay: isConsolidatedWorkspacePicker ? 0 : undefined, minWidth: pickerWidth, maxWidth: pickerWidth }; } /** From 84b9a4dfb9aa37a07e0a824cc275442158b954c8 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 23:49:37 +0100 Subject: [PATCH 155/165] Revert the action list's detail row fixes, which the folder pickers apply to their own lists instead This reverts commits a82a0184950 and 6aa8ba049eb. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../actionWidget/browser/actionWidget.css | 8 +-- .../test/browser/actionList.test.ts | 57 ------------------- 2 files changed, 3 insertions(+), 62 deletions(-) diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index 6425f8193c9c8f..81c5a46d8e1e9f 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -375,9 +375,7 @@ align-content: center; padding-right: 6px; - /* The list sets the row's line height to its height: the first line keeps to its text, so the detail fits below it */ - .title, - .description { + .title { line-height: 14px; } } @@ -449,8 +447,8 @@ /* Inline description mode — description rendered right after the label */ .action-widget .inline-description .monaco-list-row.action { - /* Override the gap between items so group-title and toolbar sit flush; a detail keeps the gap above it */ - column-gap: 0; + /* Override the row gap so group-title and toolbar sit flush */ + gap: 0; .title { flex: 0 1 auto; diff --git a/src/vs/platform/actionWidget/test/browser/actionList.test.ts b/src/vs/platform/actionWidget/test/browser/actionList.test.ts index 20497dbe09acda..201bfa86246018 100644 --- a/src/vs/platform/actionWidget/test/browser/actionList.test.ts +++ b/src/vs/platform/actionWidget/test/browser/actionList.test.ts @@ -352,63 +352,6 @@ suite('ActionListWidget', () => { }); }); - test('a row with a description and a detail keeps its detail inside it, as one without a description does, also with descriptions inline', () => { - const layoutOf = (inlineDescription: boolean) => { - const widget = createActionListWidget(disposables, { - items: [{ ...action('without'), detail: 'Start without a backing workspace' }, { ...action('with'), description: '~/work', detail: 'Works in the folder already on this computer' }, action('next')], - listOptions: { showFilter: true, inlineDescription }, - }); - const wrapper = document.createElement('div'); - wrapper.classList.add('action-widget'); - widget.domNode.parentElement?.insertBefore(wrapper, widget.domNode); - wrapper.appendChild(widget.domNode); - disposables.add({ dispose: () => wrapper.remove() }); - widget.layout(600, 400); - const rows = [...widget.domNode.querySelectorAll('.monaco-list-row.action')]; - const offsets = (row: HTMLElement) => { - const bounds = row.getBoundingClientRect(); - const detail = row.querySelector('.detail')!.getBoundingClientRect(); - const title = row.querySelector('.title')!.getBoundingClientRect(); - return { title: title.top - bounds.top, detail: detail.top - bounds.top, detailInside: detail.bottom <= bounds.bottom }; - }; - return { - without: offsets(rows[0]), - with: offsets(rows[1]), - noOverlap: rows[2].getBoundingClientRect().top >= rows[1].querySelector('.detail')!.getBoundingClientRect().bottom, - }; - }; - const result = { stacked: layoutOf(false), inline: layoutOf(true) }; - - assert.deepStrictEqual({ - stacked: { detailInside: result.stacked.with.detailInside, sameAsWithout: result.stacked.with.detail === result.stacked.without.detail && result.stacked.with.title === result.stacked.without.title, noOverlap: result.stacked.noOverlap }, - inline: { detailInside: result.inline.with.detailInside, sameAsWithout: result.inline.with.detail === result.inline.without.detail && result.inline.with.title === result.inline.without.title, noOverlap: result.inline.noOverlap }, - }, { - stacked: { detailInside: true, sameAsWithout: true, noOverlap: true }, - inline: { detailInside: true, sameAsWithout: true, noOverlap: true }, - }); - }); - - test('a row with a detail is laid out alike with descriptions inline or not: its label, and its detail below it as far apart', () => { - const offsets = (inlineDescription: boolean) => { - const widget = createActionListWidget(disposables, { - items: [{ ...action('New Worktree'), detail: 'Creates a separate copy for this session', group: { title: '', icon: Codicon.worktree } }], - listOptions: { showFilter: false, inlineDescription }, - }); - const wrapper = document.createElement('div'); - wrapper.classList.add('action-widget'); - widget.domNode.parentElement?.insertBefore(wrapper, widget.domNode); - wrapper.appendChild(widget.domNode); - disposables.add({ dispose: () => wrapper.remove() }); - widget.layout(600, 200); - const row = widget.domNode.querySelector('.monaco-list-row.action')!; - const bounds = row.getBoundingClientRect(); - const of = (selector: string) => { const b = row.querySelector(selector)!.getBoundingClientRect(); return { left: b.left - bounds.left, top: b.top - bounds.top }; }; - return { height: bounds.height, title: of('.title'), detail: of('.detail') }; - }; - - assert.deepStrictEqual(offsets(true), offsets(false)); - }); - test('wrapped details resize rows without clipping or overlapping subsequent items', () => { const widget = createActionListWidget(disposables, { items: [{ ...action('Remote environment'), detail: 'Access agents on this machine from VS Code, vscode.dev/agents, GitHub apps, or github.com/copilot. '.repeat(3) }, action('Next')], From 088fbeab31ac530d4eb0e2296ffb9d1d54bc4c36 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 23:51:38 +0100 Subject: [PATCH 156/165] Sessions: an editor window's composer lays out its choices with a detail in its own list, as the Agents window's isolation picker does Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessions/browser/chat/media/chatWidget.css | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/vs/workbench/contrib/sessions/browser/chat/media/chatWidget.css b/src/vs/workbench/contrib/sessions/browser/chat/media/chatWidget.css index 7bf434f1b65895..b86fbcad7c214d 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/media/chatWidget.css +++ b/src/vs/workbench/contrib/sessions/browser/chat/media/chatWidget.css @@ -904,6 +904,19 @@ outline-offset: var(--vscode-spacing-sizeNone); } +/* + * A picker restricted to a window's folders lays out its choices with a detail as the Agents window's isolation picker does. + * The list sets a row's line height to its height: the first line keeps to its text, as the title does, so the detail fits + * below it; and with descriptions inline, the detail keeps the list's gap above it. + */ +.action-widget .sessions-restricted-workspace-picker-list .monaco-list .monaco-list-row.action.has-detail .description { + line-height: 14px; +} + +.action-widget .sessions-restricted-workspace-picker-list.inline-description .monaco-list-row.action.has-detail { + row-gap: var(--vscode-spacing-size60, 6px); +} + /* A picker restricted to a window's folders keeps the action list's icons, which its choices' details align with. */ .action-widget .sessions-new-chat-picker-list:not(.sessions-restricted-workspace-picker-list) .monaco-list-row.action .codicon { font-size: var(--vscode-codiconFontSize); From 10573cd1bb78ee4aafd1faa02892ea8925d4c638 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 14 Nov 2026 23:51:40 +0100 Subject: [PATCH 157/165] Worktree support: the Chat view's folder picker keeps the detail of a row with a description inside the row, in its own list Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostFolderPickerActionItem.ts | 16 +++++++-- .../media/agentHostChatInputPicker.css | 8 +++++ .../agentHostFolderPickerActionItem.test.ts | 35 ++++++++++++++++++- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts index 27c7fdf469aecf..39fe860f31dcfc 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.ts @@ -18,7 +18,7 @@ import { mainWindow } from '../../../../../../base/browser/window.js'; import { localize } from '../../../../../../nls.js'; import { MenuItemAction } from '../../../../../../platform/actions/common/actions.js'; import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; -import { IActionWidgetDropdownAction, IActionWidgetDropdownActionProvider, IActionWidgetDropdownOptions } from '../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; +import { IActionWidgetDropdownAction, IActionWidgetDropdownActionProvider, IActionWidgetDropdownListOptionsProvider, IActionWidgetDropdownOptions } from '../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -45,6 +45,18 @@ import './media/agentHostChatInputPicker.css'; * directory is fixed, so the chip stays visible (showing the chosen folder) * but is disabled via the action's precondition. */ +/** The class of the folder picker's list, whose rows with a detail are laid out in agentHostChatInputPicker.css. */ +export const FOLDER_PICKER_LIST_CLASS = 'agent-host-folder-picker-list'; + +function withFolderPickerListClass(provider: IActionWidgetDropdownListOptionsProvider): IActionWidgetDropdownListOptionsProvider { + return { + getListOptions: () => { + const options = provider.getListOptions(); + return { ...options, className: [options?.className, FOLDER_PICKER_LIST_CLASS].filter(Boolean).join(' ') }; + }, + }; +} + /** As `.agent-host-folder-chip .chat-input-picker-label` bounds the name in agentHostChatInputPicker.css. */ const FOLDER_CHIP_LABEL_MAX_WIDTH = 160; @@ -124,7 +136,7 @@ export class AgentHostFolderPickerActionItem extends ChatInputPickerActionViewIt actionBarActionProvider, showItemKeybindings: false, reporter: { id: 'AgentHostFolderPicker', name: 'AgentHostFolderPicker', includeOptions: false }, - listOptionsProvider: createFolderPickerTip(storageService), + listOptionsProvider: withFolderPickerListClass(createFolderPickerTip(storageService)), }; super(action, folderPickerOptions, pickerOptions, actionWidgetService, keybindingService, contextKeyService, telemetryService); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css index 1b53df3087a845..eb4a2b5db3b3a7 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css @@ -182,3 +182,11 @@ overflow: hidden; text-overflow: ellipsis; } + +/* + * The folder picker's rows with a detail, such as the folder with Current beside its name: the list sets a row's line + * height to its height, so the first line keeps to its text as the title does, and the detail fits below it. + */ +.action-widget .agent-host-folder-picker-list .monaco-list .monaco-list-row.action.has-detail .description { + line-height: 14px; +} diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.test.ts index c5b79058599720..044420ab9b8848 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostFolderPickerActionItem.test.ts @@ -5,6 +5,15 @@ import assert from 'assert'; import { IContextMenuProvider } from '../../../../../../../base/browser/contextmenu.js'; +import { Codicon } from '../../../../../../../base/common/codicons.js'; +import { ActionListItemKind, ActionListWidget, IActionListItem } from '../../../../../../../platform/actionWidget/browser/actionList.js'; +import { IHoverService } from '../../../../../../../platform/hover/browser/hover.js'; +import { NullHoverService } from '../../../../../../../platform/hover/test/browser/nullHoverService.js'; +import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IKeybindingService } from '../../../../../../../platform/keybinding/common/keybinding.js'; +import { MockKeybindingService } from '../../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; +import { NullOpenerService } from '../../../../../../../platform/opener/test/common/nullOpenerService.js'; import { mainWindow } from '../../../../../../../base/browser/window.js'; import { ToolBar } from '../../../../../../../base/browser/ui/toolbar/toolbar.js'; import { Action } from '../../../../../../../base/common/actions.js'; @@ -22,7 +31,7 @@ import { workbenchInstantiationService } from '../../../../../../test/browser/wo import { ISCMService } from '../../../../../scm/common/scm.js'; import type { IChatWidget } from '../../../../browser/chat.js'; import { IAgentHostCustomizationService } from '../../../../browser/agentSessions/agentHost/agentHostCustomizationService.js'; -import { AgentHostFolderPickerActionItem } from '../../../../browser/agentSessions/agentHost/agentHostFolderPickerActionItem.js'; +import { AgentHostFolderPickerActionItem, FOLDER_PICKER_LIST_CLASS } from '../../../../browser/agentSessions/agentHost/agentHostFolderPickerActionItem.js'; import { IAgentHostNewSessionFolderService } from '../../../../browser/agentSessions/agentHost/agentHostNewSessionFolderService.js'; import { IAgentHostUntitledProvisionalSessionService } from '../../../../browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; import { AgentHostWorktreeSessionSources, IAgentHostWorktreeSessionSources } from '../../../../browser/agentSessions/agentHost/agentHostWorktreeSessionSources.js'; @@ -275,4 +284,28 @@ suite('AgentHostFolderPickerActionItem', () => { inFolder: { label: 'fix', ariaLabel: 'Folder: fix', noWorkspaceIcon: false }, }); }); + test('its list keeps the detail of a row with a description, such as the folder with Current, inside the row, as it does a row without one', () => { + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.set(IKeybindingService, new MockKeybindingService()); + instantiationService.set(IHoverService, NullHoverService); + instantiationService.set(IOpenerService, NullOpenerService); + // As the dropdown shows the picker's choices: each by its icon, with what it does below its name + const items: IActionListItem[] = [ + { kind: ActionListItemKind.Action, label: 'No Workspace', detail: 'Start without a backing workspace', group: { title: '', icon: Codicon.comment }, item: {} }, + { kind: ActionListItemKind.Action, label: 'vscode', description: 'Current', detail: 'Works in the folder already on this computer', group: { title: '', icon: Codicon.folder }, item: {} }, + { kind: ActionListItemKind.Action, label: 'New Worktree', detail: 'Creates a separate copy for this session', group: { title: '', icon: Codicon.worktree }, item: {} }, + ]; + const widget = store.add(instantiationService.createInstance(ActionListWidget, 'folders', false, items, { onHide: () => { }, onSelect: () => { } }, undefined, { className: FOLDER_PICKER_LIST_CLASS })); + root.classList.add('action-widget'); + root.appendChild(widget.domNode); + widget.layout(400, 400); + const rows = [...widget.domNode.querySelectorAll('.monaco-list-row.action')]; + const offsets = (row: HTMLElement) => { + const bounds = row.getBoundingClientRect(); + const detail = row.querySelector('.detail')!.getBoundingClientRect(); + return { title: row.querySelector('.title')!.getBoundingClientRect().top - bounds.top, detail: detail.top - bounds.top, inside: detail.bottom <= bounds.bottom }; + }; + + assert.deepStrictEqual(offsets(rows[1]), offsets(rows[0])); + }); }); From 6540229b8928b6f5c8f44cfef30e4bcb129211fe Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sun, 15 Nov 2026 00:31:11 +0100 Subject: [PATCH 158/165] Worktree support: the chat input component fixtures stub whether a new session has no workspace Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/browser/componentFixtures/chat/chatFixtureUtils.ts | 1 + .../componentFixtures/editor/inlineChatZoneWidget.fixture.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts index b03e4e89667378..d58e2da5acacbb 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts @@ -532,6 +532,7 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I reg.defineInstance(IAgentHostNewSessionFolderService, new class extends mock() { override readonly onDidChangeFolder = Event.None; override getFolder() { return undefined; } + override isNoWorkspace() { return false; } override getDefaultFolder() { return undefined; } override resolveNewSessionPrimary() { return undefined; } }()); diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts index b8ff167b42b286..e18f3a245eb023 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts @@ -321,6 +321,7 @@ function renderInlineChatZoneWidget({ container, disposableStore, theme, focus } reg.defineInstance(IAgentHostNewSessionFolderService, new class extends mock() { override readonly onDidChangeFolder = Event.None; override getFolder() { return undefined; } + override isNoWorkspace() { return false; } }()); reg.defineInstance(IAgentHostCustomizationService, new class extends mock() { override readonly onDidChangeCustomizations = Event.None; From 46793f65d27f798c6bd5bfbae2a3fa815d18caa6 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sun, 15 Nov 2026 01:05:59 +0100 Subject: [PATCH 159/165] Agent host: offer the worktree folder chip only for local agent host chats (CCR) A remote agent host chat in a local window resolves the requested working directory to none, so the chip's choice was ignored. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostChatInputPicker.contribution.ts | 2 ++ .../agentHostChatInputPicker.test.ts | 21 +++++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts index b16bc4869cfb55..414bb676a820d5 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts @@ -77,6 +77,8 @@ export class OpenAgentHostFolderPickerAction extends Action2 { when: ContextKeyExpr.and( ChatContextKeys.enabled, ChatContextKeyExprs.isAgentHostSession, + // Of the local agent host only: a remote agent host's session works where its own host says + ContextKeyExpr.regex(ChatContextKeys.chatSessionType.key, /^agent-host-/), ContextKeyExpr.equals(`config.${ChatConfiguration.EditorWindowEnableWorktree}`, true), WorkbenchStateContext.isEqualTo('folder'), RemoteNameContext.isEqualTo(''), diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts index c14203bcc03a7c..0024d7b1e9459c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts @@ -54,7 +54,7 @@ import { IChatAcceptInputOptions, IChatWidget } from '../../../browser/chat.js'; import { IChatRequestModel } from '../../../common/model/chatModel.js'; import { IChatViewModel } from '../../../common/model/chatViewModel.js'; import { TestStorageService, TestWorkspaceTrustManagementService } from '../../../../../test/common/workbenchTestServices.js'; -import { IsSessionsWindowContext } from '../../../../../common/contextkeys.js'; +import { IsSessionsWindowContext, RemoteNameContext, WorkbenchStateContext } from '../../../../../common/contextkeys.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import * as dom from '../../../../../../base/browser/dom.js'; import { toDisposable } from '../../../../../../base/common/lifecycle.js'; @@ -71,7 +71,7 @@ import { ActionType } from '../../../../../../platform/agentHost/common/state/pr import { AgentHostChatInputPicker, getAgentHostSandboxSettingId, getConfigPickerAccessibleTriggerLabel, getConfigPickerItemHover, getConfigPickerListOptions, getConfigPickerTriggerHover, getConfigPickerTriggerLabel, isGenericConfigPickerProperty, resolveConfigChipValue } from '../../../browser/agentSessions/agentHost/agentHostChatInputPicker.js'; import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../../../platform/sandbox/common/settings.js'; import { IChatSessionOptionsChangeEvent, IChatSessionsService, SessionType } from '../../../common/chatSessionsService.js'; -import { getAgentHostPickerProperty, OpenAgentHostAutoApprovePickerAction, OpenAgentHostCodexApprovalsPickerAction, OpenAgentHostModePickerAction, OpenAgentHostPermissionModePickerAction } from '../../../browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.js'; +import { getAgentHostPickerProperty, OpenAgentHostAutoApprovePickerAction, OpenAgentHostFolderPickerAction, OpenAgentHostCodexApprovalsPickerAction, OpenAgentHostModePickerAction, OpenAgentHostPermissionModePickerAction } from '../../../browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.js'; import { isAutoApproveValuePolicyRestricted, normalizeSessionConfigValue } from '../../../common/agentHostConfigPolicy.js'; import { ChatConfiguration, ChatPermissionLevel } from '../../../common/constants.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; @@ -396,6 +396,23 @@ suite('AgentHostChatInputPicker - combined mode and permissions', () => { }, { cloud: [OpenAgentHostModePickerAction.ID, OpenAgentHostAutoApprovePickerAction.ID], sessions: [], disabled: [], local: [] }); }); + test('the folder chip of worktree support shows for a local agent host chat in a local single-folder window, not for a remote agent host chat', () => { + const worktreeItem = MenuRegistry.getMenuItems(MenuId.ChatInputSecondary).filter(isIMenuItem) + .find(item => item.command.id === OpenAgentHostFolderPickerAction.ID && item.when?.keys().includes(`config.${ChatConfiguration.EditorWindowEnableWorktree}`)); + const visible = (type: string) => { + const context = new Context(0, null); + context.setValue(ChatContextKeys.enabled.key, true); + context.setValue(ChatContextKeys.chatIsAgentHostSession.key, true); + context.setValue(ChatContextKeys.chatSessionType.key, type); + context.setValue(`config.${ChatConfiguration.EditorWindowEnableWorktree}`, true); + context.setValue(WorkbenchStateContext.key, 'folder'); + context.setValue(RemoteNameContext.key, ''); + context.setValue(IsSessionsWindowContext.key, false); + return !!worktreeItem?.when?.evaluate(context); + }; + assert.deepStrictEqual({ local: visible('agent-host-copilot'), remote: visible('remote-localhost__8081-copilot') }, { local: true, remote: false }); + }); + test('cloud drafts reuse the combined picker without an agent host connection', async () => { const rig = setupCloudDraft(); await rig.modePicker['_showPicker'](rig.modePicker.triggerElement!, true); From 023023ba95002ca1aea6ee90e97aea134b68bbde Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sun, 15 Nov 2026 01:06:03 +0100 Subject: [PATCH 160/165] Sessions: name New Worktree from a worktree by the worktree when several folders are allowed (CCR) Which folder the worktree is of is not known then, so the first folder was named wrongly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/test/browser/sessionWorkspacePicker.test.ts | 12 ++++++++++++ .../sessions/browser/chat/sessionWorkspacePicker.ts | 6 ++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index 0c1fe6164efe4e..39e0b195f0919b 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -9100,6 +9100,18 @@ suite('WorkspacePicker - Allowed folders', () => { }); }); + test('New Worktree from a worktree of one of several folders names the worktree, not another folder, as it cannot tell which folder the worktree is of', async () => { + const newWorktree = { selected: false }; + const { picker } = createPicker(['/local/alpha', '/local/beta', '/local/beta.worktrees/b'], undefined, { + getAllowedFolderPresentation: folderUri => folderUri.path === '/local/beta.worktrees/b' ? { label: 'b', kind: 'worktree' } : { kind: 'folder' }, + newWorktree: { isAvailable: () => true, isSelected: () => newWorktree.selected, setSelected: selected => { newWorktree.selected = selected; } }, + }); + picker.setSelectedWorkspace(URI.file('/local/beta.worktrees/b'), { fireEvent: false }); + await picker.dispatchItem(picker.getItems().find(item => item.label === 'New Worktree')!.item!); + + assert.deepStrictEqual({ hover: picker.getSelectedLocationDescription(), selected: newWorktree.selected }, { hover: 'New worktree of b', selected: true }); + }); + test('at a worktree selected by another form of its path, which no provider resolves, selects the worktree the window\'s folder is at, as one selection with its events, so its row is checked, its chip shows it and New Worktree sticks', async () => { const newWorktree = { selected: false }; const { picker } = createPicker(['/local/vscode.worktrees/a', '/local/vscode', '/private/local/vscode.worktrees/a'], undefined, { diff --git a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts index bf74ae64a04c80..0ee28ff11efdfc 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts +++ b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts @@ -2995,8 +2995,10 @@ export class WorkspacePicker extends Disposable { } const name = presentation.label ?? this._getFolderWorkspaceLabel(workspace, providerId); if (this._isSelectedFolder(folderUri) && this._isNewWorktreeSelected()) { - // A new worktree of the window's folder, also from one of its worktrees - const primary = presentation.kind === 'worktree' ? this._getAllowedFolders()?.find(candidate => this.options.getAllowedFolderPresentation?.(candidate)?.kind === 'folder') : undefined; + // A new worktree of the window's folder, also from one of its worktrees; with several folders, which one a worktree is + // of is not known, so it is named by the worktree itself + const folders = presentation.kind === 'worktree' ? this._getAllowedFolders()?.filter(candidate => this.options.getAllowedFolderPresentation?.(candidate)?.kind === 'folder') : undefined; + const primary = folders?.length === 1 ? folders[0] : undefined; const primaryResolved = primary ? this._resolveFolder(primary) : undefined; const of = primary && primaryResolved ? this.options.getAllowedFolderPresentation?.(primary)?.label ?? this._getFolderWorkspaceLabel(primaryResolved.workspace, primaryResolved.providerId) : name; return { icon: WORKSPACE_PICKER_WORKTREE_ICON, label: getNewWorktreeLabel(), description: localize('workspacePicker.newWorktreeChipDescription', "New worktree of {0}", of) }; From 0e6ee63b141f1707d7f51a7f0f83a39a8be313b1 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sun, 15 Nov 2026 03:44:21 +0100 Subject: [PATCH 161/165] Agent host: offer a worktree that sessions reach by several paths once (CCR) A worktree that sessions work in through a symbolic link and through its real path showed twice in the folder picker. Locations are now deduplicated by their real path, keeping the first. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostWorktreeLocations.ts | 15 +++++++++++++-- .../agentHost/agentHostWorktreeLocations.test.ts | 6 ++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts index f42facd1514749..50fbf2d718c403 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostWorktreeLocations.ts @@ -103,14 +103,25 @@ export class AgentHostWorktreeLocationProvider extends Disposable implements IWo } const locations = await Promise.all([...candidates.values()].map(async candidate => { try { - return await this._resolve(worktreesDir, primary, candidate); + const location = await this._resolve(worktreesDir, primary, candidate); + return location && { location, real: await this._real(candidate) }; } catch (error) { // One working directory that fails to read, such as a pruned worktree, does not hide the others this._logService.warn(`[AgentHostWorktreeLocations] Could not read the git checkout of ${candidate.toString()}`, error); return undefined; } })); - return token.isCancellationRequested ? [] : locations.filter(isDefined); + if (token.isCancellationRequested) { + return []; + } + // A worktree that sessions work in by several paths, such as through a symbolic link, is offered once, by the first + const offered = new ResourceMap(uri => extUriBiasedIgnorePathCase.getComparisonKey(uri)); + for (const resolved of locations.filter(isDefined)) { + if (!offered.has(resolved.real)) { + offered.set(resolved.real, resolved.location); + } + } + return [...offered.values()]; } async resolveLocation(primary: URI, location: URI, token: CancellationToken): Promise { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts index ca2d7f63854b67..acb75b12d1246e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/agentHostWorktreeLocations.test.ts @@ -192,6 +192,12 @@ suite('AgentHostWorktreeLocations', () => { }, { throughLinkedPrimary: 'feature', linkedWorktree: 'feature', primaryItself: 'none', offered: ['/alias/feature'] }); }); + test('offers a worktree that sessions work in through a symbolic link and through its real path once, by the form a session named first', async () => { + const provider = createProvider(repository, [session(SessionType.AgentHostCopilot, '/alias/feature'), session(SessionType.AgentHostCopilot, '/work/vscode.worktrees/feature')], [], { '/alias/feature': '/work/vscode.worktrees/feature' }); + const offered = (await provider.provideLocations(URI.file('/work/vscode'), CancellationToken.None)).map(location => location.uri.path); + assert.deepStrictEqual(offered, ['/alias/feature']); + }); + test('follows symbolic links of the repository\'s worktrees folder and of a worktree\'s .git file, and grants the primary\'s trust only to the checkout git links back, not to another folder whose .git links to the same file', async () => { // The repository's `.git/worktrees` is a link to another folder, and the worktree's `.git` file a link to another // file; git names the worktree's own `.git` back. The impostor's `.git` links to the same file. From c85967f644a4f1f3386eed6e1b2952f7a9ecd119 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sun, 15 Nov 2026 03:45:20 +0100 Subject: [PATCH 162/165] Sessions: count an allowed folder without a kind as a folder when naming New Worktree's folder (CCR) The section builder shows such a folder as a folder, so with it beside another folder, New Worktree from a worktree is named by the worktree, not by the other folder. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/test/browser/sessionWorkspacePicker.test.ts | 12 ++++++++++++ .../sessions/browser/chat/sessionWorkspacePicker.ts | 6 +++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index 39e0b195f0919b..fa578bdc4200d0 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -9112,6 +9112,18 @@ suite('WorkspacePicker - Allowed folders', () => { assert.deepStrictEqual({ hover: picker.getSelectedLocationDescription(), selected: newWorktree.selected }, { hover: 'New worktree of b', selected: true }); }); + test('New Worktree from a worktree, beside a folder with a kind and one without, which shows as a folder too, names the worktree', async () => { + const newWorktree = { selected: false }; + const { picker } = createPicker(['/local/alpha', '/local/beta', '/local/beta.worktrees/b'], undefined, { + getAllowedFolderPresentation: folderUri => folderUri.path === '/local/beta.worktrees/b' ? { label: 'b', kind: 'worktree' } : folderUri.path === '/local/alpha' ? { kind: 'folder' } : undefined, + newWorktree: { isAvailable: () => true, isSelected: () => newWorktree.selected, setSelected: selected => { newWorktree.selected = selected; } }, + }); + picker.setSelectedWorkspace(URI.file('/local/beta.worktrees/b'), { fireEvent: false }); + await picker.dispatchItem(picker.getItems().find(item => item.label === 'New Worktree')!.item!); + + assert.deepStrictEqual(picker.getSelectedLocationDescription(), 'New worktree of b'); + }); + test('at a worktree selected by another form of its path, which no provider resolves, selects the worktree the window\'s folder is at, as one selection with its events, so its row is checked, its chip shows it and New Worktree sticks', async () => { const newWorktree = { selected: false }; const { picker } = createPicker(['/local/vscode.worktrees/a', '/local/vscode', '/private/local/vscode.worktrees/a'], undefined, { diff --git a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts index 0ee28ff11efdfc..d16642d637b0f0 100644 --- a/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts +++ b/src/vs/workbench/contrib/sessions/browser/chat/sessionWorkspacePicker.ts @@ -2995,9 +2995,9 @@ export class WorkspacePicker extends Disposable { } const name = presentation.label ?? this._getFolderWorkspaceLabel(workspace, providerId); if (this._isSelectedFolder(folderUri) && this._isNewWorktreeSelected()) { - // A new worktree of the window's folder, also from one of its worktrees; with several folders, which one a worktree is - // of is not known, so it is named by the worktree itself - const folders = presentation.kind === 'worktree' ? this._getAllowedFolders()?.filter(candidate => this.options.getAllowedFolderPresentation?.(candidate)?.kind === 'folder') : undefined; + // A new worktree of the window's folder, also from one of its worktrees; with several folders, also one without a kind, + // which shows as a folder, which one a worktree is of is not known, so it is named by the worktree itself + const folders = presentation.kind === 'worktree' ? this._getAllowedFolders()?.filter(candidate => (this.options.getAllowedFolderPresentation?.(candidate)?.kind ?? 'folder') === 'folder') : undefined; const primary = folders?.length === 1 ? folders[0] : undefined; const primaryResolved = primary ? this._resolveFolder(primary) : undefined; const of = primary && primaryResolved ? this.options.getAllowedFolderPresentation?.(primary)?.label ?? this._getFolderWorkspaceLabel(primaryResolved.workspace, primaryResolved.providerId) : name; From 1b8da23766e9f103210e3571c4346e934cd980c2 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sun, 15 Nov 2026 12:52:00 +0100 Subject: [PATCH 163/165] Worktree support: stop re-reading every session as busy sessions update without moving The session layer's source swept every session, chat and workspace on each session update, such as a status, summary or git-state change of a busy session, inside the provider's transaction. It now reads every session again only as sessions are added or removed or a provider invalidates its list, and keys each chat and session by its working directories with strict equality, so an update that moves nothing stops at that chat's key. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostWorktreeComposerFolders.ts | 59 ++++++++++-------- .../agentHostWorktreeComposerFolders.test.ts | 62 +++++++++++++++++-- 2 files changed, 91 insertions(+), 30 deletions(-) diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts index 5eced27b21eac4..f6d9bfa5a0e379 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts @@ -6,9 +6,10 @@ import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { RunOnceScheduler } from '../../../../../base/common/async.js'; import { onUnexpectedError } from '../../../../../base/common/errors.js'; -import { Emitter } from '../../../../../base/common/event.js'; +import { strictEquals } from '../../../../../base/common/equals.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { Disposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { autorun, derived, IObservable, observableSignalFromEvent, observableValue } from '../../../../../base/common/observable.js'; +import { autorun, derived, derivedOpts, IObservable, observableSignalFromEvent, observableValue } from '../../../../../base/common/observable.js'; import { extUriBiasedIgnorePathCase } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -24,7 +25,7 @@ import { ChatConfiguration } from '../../../chat/common/constants.js'; import { IAllowedFolderPresentation } from '../chat/sessionWorkspacePicker.js'; import { IEditorWindowComposerFoldersService } from '../editorWindow/editorWindowComposerFolders.js'; import { IAgentHostWorktreeSessionSource, IAgentHostWorktreeSessionSources } from '../../../chat/browser/agentSessions/agentHost/agentHostWorktreeSessionSources.js'; -import { ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; +import { IChat, ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../common/agentHostSessionsProvider.js'; import { ISessionsManagementService } from '../../common/sessionsManagement.js'; @@ -171,30 +172,38 @@ export class AgentHostWorktreeSessionLayerSource extends Disposable implements I @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, ) { super(); - // As any session it knows is added, removed or changes, also one outside the listed ones (getAllSessions), and as a - // session comes to work elsewhere, such as in the worktree its first request created - const sessionsChanged = observableSignalFromEvent(this, _sessionsManagementService.onDidChangeSession); - // Only as where they work changes, not as they change otherwise, such as their status, which reads no folders again + // Read again as sessions are added or removed, also one outside the listed ones (getAllSessions), or as a provider + // invalidates what it lists (an empty batch); not as a session changes otherwise, which its own observables tell + const membershipChanged = observableSignalFromEvent(this, Event.filter(_sessionsManagementService.onDidChangeSession, changes => !changes.length || changes.some(change => change.kind !== 'changed'))); + // Where each session and chat works, by a key that changes only as where it works does: a busy session's updates, such + // as of its status or git state, make its workspaces anew without moving them, and stop at its own key + const directories = (workspace: ISessionWorkspace | undefined) => workspace?.folders.map(folder => folder.workingDirectory.toString()).join(',') ?? ''; + const chatKeys = new WeakMap>(); + const chatKey = (session: ISession, chat: IChat) => { + let key = chatKeys.get(chat); + if (!key) { + // Every folder it works in, which the worktrees count; the first is where it follows + key = derivedOpts({ owner: this, equalsFn: strictEquals }, reader => `${chat.resource.toString()}=${directories(chat.workspace.read(reader) ?? session.workspace.read(reader))}`); + chatKeys.set(chat, key); + } + return key; + }; + const sessionKeys = new WeakMap>(); + const sessionKey = (session: ISession) => { + let key = sessionKeys.get(session); + if (!key) { + const own = derivedOpts({ owner: this, equalsFn: strictEquals }, reader => `*=${directories(session.workspace.read(reader))}`); + key = derivedOpts({ owner: this, equalsFn: strictEquals }, reader => session.providerId !== LOCAL_AGENT_HOST_PROVIDER_ID || session.isArchived.read(reader) + ? '' + : [...session.chats.read(reader).map(chat => chatKey(session, chat).read(reader)), own.read(reader)].join('\n')); + sessionKeys.set(session, key); + } + return key; + }; let known: string | undefined; this._register(autorun(reader => { - sessionsChanged.read(reader); - const working: string[] = []; - const directories = (workspace: ISessionWorkspace | undefined) => workspace?.folders.map(folder => folder.workingDirectory.toString()).join(',') ?? ''; - for (const session of _sessionsManagementService.getAllSessions()) { - const local = session.providerId === LOCAL_AGENT_HOST_PROVIDER_ID && !session.isArchived.read(reader); - const sessionWorkspace = session.workspace.read(reader); - for (const chat of session.chats.read(reader)) { - const chatWorkspace = chat.workspace.read(reader); - if (local) { - // Every folder it works in, which the worktrees count; the first is where it follows - working.push(`${chat.resource.toString()}=${directories(chatWorkspace ?? sessionWorkspace)}`); - } - } - if (local) { - working.push(`*=${directories(sessionWorkspace)}`); - } - } - const key = working.join('\n'); + membershipChanged.read(reader); + const key = _sessionsManagementService.getAllSessions().map(session => sessionKey(session).read(reader)).filter(Boolean).join('\n'); if (known !== undefined && key !== known) { this._onDidChange.fire(); } diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts index 61b9c4bd4d10ef..5b478cd0c10d7d 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts @@ -8,7 +8,7 @@ import { DeferredPromise, timeout } from '../../../../../../base/common/async.js import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; -import { observableValue } from '../../../../../../base/common/observable.js'; +import { observableValue, transaction } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -27,7 +27,7 @@ import { ChatConfiguration } from '../../../../chat/common/constants.js'; import { EditorWindowComposerFoldersService, IEditorWindowComposerFolderOptions, IEditorWindowComposerFoldersService } from '../../../browser/editorWindow/editorWindowComposerFolders.js'; import { AgentHostWorktreeComposerFolders, AgentHostWorktreeComposerFoldersContribution, AgentHostWorktreeSessionLayerSource } from '../../../browser/agentsView/agentHostWorktreeComposerFolders.js'; import { AgentHostWorktreeSessionSources, IAgentHostWorktreeSessionSources } from '../../../../chat/browser/agentSessions/agentHost/agentHostWorktreeSessionSources.js'; -import { ISessionsManagementService } from '../../../common/sessionsManagement.js'; +import { ISessionChange, ISessionsManagementService } from '../../../common/sessionsManagement.js'; import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../common/agentHostSessionsProvider.js'; import { IChat, ISession, ISessionWorkspace } from '../../../../../services/sessions/common/session.js'; @@ -358,7 +358,7 @@ suite('AgentHostWorktreeComposerFolders', () => { }); const sessions: ISession[] = []; // Only every session's changes, not the listed ones', report it - const onDidChangeSession = store.add(new Emitter()); + const onDidChangeSession = store.add(new Emitter()); const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(ISessionsManagementService, upcastPartial({ onDidChangeSessions: Event.None, @@ -370,10 +370,10 @@ suite('AgentHostWorktreeComposerFolders', () => { let changes = 0; store.add(source.onDidChange(() => changes++)); // Any session changing otherwise, such as its status, changes nothing of where they work - onDidChangeSession.fire(undefined as never); + onDidChangeSession.fire([{ session: hidden, kind: 'changed' }]); const unrelated = changes; sessions.push(hidden); - onDidChangeSession.fire(undefined as never); + onDidChangeSession.fire([{ session: hidden, kind: 'added' }]); const added = changes; chatWorkspace.set(workspace('/work/vscode.worktrees/hidden'), undefined); @@ -386,6 +386,58 @@ suite('AgentHostWorktreeComposerFolders', () => { }); }); + test('the session layer\'s source does not read every session again as busy sessions update without moving, such as their status or git state, but does as one moves', () => { + const folders = (path: string) => [{ root: URI.file('/work/vscode'), workingDirectory: URI.file(path), name: 'vscode', description: undefined }]; + // A provider's chat workspace is a new object as anything of it changes, also where it works in the same folders + let folderReads = 0; + const workspace = (path: string) => { + const value = folders(path); + return upcastPartial({ get folders() { folderReads++; return value; } }); + }; + const sessionCount = 5, chatCount = 4, updates = 20; + const chatWorkspaces: ReturnType>[] = []; + const sessions = Array.from({ length: sessionCount }, (_, i) => upcastPartial({ + providerId: LOCAL_AGENT_HOST_PROVIDER_ID, + isArchived: observableValue('archived', false), + workspace: observableValue('workspace', workspace(`/work/vscode.worktrees/s${i}`)), + chats: observableValue('chats', Array.from({ length: chatCount }, (_, j) => { + const chatWorkspace = observableValue('workspace', workspace(`/work/vscode.worktrees/s${i}`)); + chatWorkspaces.push(chatWorkspace); + return upcastPartial({ resource: URI.from({ scheme: 'ahp-chat', path: `/s${i}/c${j}` }), workspace: chatWorkspace }); + })), + })); + let sweeps = 0; + const onDidChangeSession = store.add(new Emitter()); + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(ISessionsManagementService, upcastPartial({ + onDidChangeSession: onDidChangeSession.event, + getAllSessions: () => { sweeps++; return sessions; }, + getSessionForChatResource: () => undefined, + })); + const source = store.add(instantiationService.createInstance(AgentHostWorktreeSessionLayerSource)); + let changes = 0; + store.add(source.onDidChange(() => changes++)); + const initial = { sweeps, folderReads }; + sweeps = folderReads = 0; + + // The first session is busy: each update of it reports it changed and its chat's workspace anew, where it already works + for (let k = 0; k < updates; k++) { + transaction(tx => { + chatWorkspaces[0].set(workspace('/work/vscode.worktrees/s0'), tx); + onDidChangeSession.fire([{ session: sessions[0], kind: 'changed' }]); + }); + } + const busy = { sweeps, folderReads, changes }; + chatWorkspaces[0].set(workspace('/work/vscode.worktrees/moved'), undefined); + + assert.deepStrictEqual({ initial, busy, moved: changes }, { + initial: { sweeps: 1, folderReads: sessionCount * (chatCount + 1) }, + // Only the busy chat's own workspace is read again, once per update + busy: { sweeps: 0, folderReads: updates, changes: 0 }, + moved: 1, + }); + }); + test('the session layer\'s source tells as a session comes to work in a new worktree in a folder other than its first', () => { const folder = (path: string) => ({ root: URI.file(path), workingDirectory: URI.file(path), name: path, description: undefined }); const sessionWorkspace = observableValue('workspace', upcastPartial({ folders: [folder('/work/other'), folder('/work/vscode')] })); From 453649c5713499370b9927c8b29e6629e6e9dc73 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sun, 15 Nov 2026 13:34:08 +0100 Subject: [PATCH 164/165] Worktree support: follow a session that its provider replaces by a new object of it A committed draft replaces the draft's session object under the same id, reported only as changed. The session layer's source kept reading the replaced object until another session was added or removed; it now reads every session again as a changed session is not one it reads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostWorktreeComposerFolders.ts | 12 ++++--- .../agentHostWorktreeComposerFolders.test.ts | 36 +++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts b/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts index f6d9bfa5a0e379..c6e68939e8e90a 100644 --- a/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts +++ b/src/vs/workbench/contrib/sessions/browser/agentsView/agentHostWorktreeComposerFolders.ts @@ -172,9 +172,11 @@ export class AgentHostWorktreeSessionLayerSource extends Disposable implements I @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, ) { super(); - // Read again as sessions are added or removed, also one outside the listed ones (getAllSessions), or as a provider - // invalidates what it lists (an empty batch); not as a session changes otherwise, which its own observables tell - const membershipChanged = observableSignalFromEvent(this, Event.filter(_sessionsManagementService.onDidChangeSession, changes => !changes.length || changes.some(change => change.kind !== 'changed'))); + // Read again as sessions are added or removed, also one outside the listed ones (getAllSessions), as a provider + // invalidates what it lists (an empty batch), or as it replaces a session by a new object of it, such as a committed + // draft, which it reports as changed; not as a session it reads changes otherwise, which its own observables tell + let tracked = new Set(); + const membershipChanged = observableSignalFromEvent(this, Event.filter(_sessionsManagementService.onDidChangeSession, changes => !changes.length || changes.some(change => change.kind !== 'changed' || !tracked.has(change.session)))); // Where each session and chat works, by a key that changes only as where it works does: a busy session's updates, such // as of its status or git state, make its workspaces anew without moving them, and stop at its own key const directories = (workspace: ISessionWorkspace | undefined) => workspace?.folders.map(folder => folder.workingDirectory.toString()).join(',') ?? ''; @@ -203,7 +205,9 @@ export class AgentHostWorktreeSessionLayerSource extends Disposable implements I let known: string | undefined; this._register(autorun(reader => { membershipChanged.read(reader); - const key = _sessionsManagementService.getAllSessions().map(session => sessionKey(session).read(reader)).filter(Boolean).join('\n'); + const sessions = _sessionsManagementService.getAllSessions(); + tracked = new Set(sessions); + const key = sessions.map(session => sessionKey(session).read(reader)).filter(Boolean).join('\n'); if (known !== undefined && key !== known) { this._onDidChange.fire(); } diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts index 5b478cd0c10d7d..047e4159659a90 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts @@ -438,6 +438,42 @@ suite('AgentHostWorktreeComposerFolders', () => { }); }); + test('the session layer\'s source follows a session that its provider replaces by a new object of the same session, such as a committed draft, and no longer the one it replaced', () => { + const workspace = (path: string) => upcastPartial({ folders: [{ root: URI.file('/work/vscode'), workingDirectory: URI.file(path), name: 'vscode', description: undefined }] }); + const create = (path: string) => { + const chatWorkspace = observableValue('workspace', workspace(path)); + const chat = upcastPartial({ resource: URI.from({ scheme: 'ahp-chat', path: '/draft' }), workspace: chatWorkspace }); + const chats = observableValue('chats', [chat]); + const isArchived = observableValue('archived', false); + const session = upcastPartial({ sessionId: 'draft', resource: URI.from({ scheme: 'ahp', path: '/draft' }), providerId: LOCAL_AGENT_HOST_PROVIDER_ID, isArchived, workspace: observableValue('workspace', workspace(path)), chats }); + return { session, chatWorkspace, chats, isArchived }; + }; + const skeleton = create('/work/vscode'); + const sessions = [skeleton.session]; + const onDidChangeSession = store.add(new Emitter()); + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(ISessionsManagementService, upcastPartial({ onDidChangeSession: onDidChangeSession.event, getAllSessions: () => sessions, getSessionForChatResource: () => undefined })); + const source = store.add(instantiationService.createInstance(AgentHostWorktreeSessionLayerSource)); + let changes = 0; + store.add(source.onDidChange(() => changes++)); + + // The provider replaces it by the committed session, of the same id, and reports only that it changed + const committed = create('/work/vscode'); + sessions[0] = committed.session; + onDidChangeSession.fire([{ session: committed.session, kind: 'changed' }]); + const replaced = changes; + // The committed session moves first, as its first request creates its worktree + committed.chatWorkspace.set(workspace('/work/vscode.worktrees/committed'), undefined); + const moved = changes; + committed.chats.set([...committed.chats.get(), upcastPartial({ resource: URI.from({ scheme: 'ahp-chat', path: '/second' }), workspace: observableValue('workspace', workspace('/work/vscode.worktrees/second')) })], undefined); + const chatAdded = changes; + skeleton.chatWorkspace.set(workspace('/work/vscode.worktrees/stale'), undefined); + const staleMoved = changes; + committed.isArchived.set(true, undefined); + + assert.deepStrictEqual({ replaced, moved, chatAdded, staleMoved, archived: changes }, { replaced: 0, moved: 1, chatAdded: 2, staleMoved: 2, archived: 3 }); + }); + test('the session layer\'s source tells as a session comes to work in a new worktree in a folder other than its first', () => { const folder = (path: string) => ({ root: URI.file(path), workingDirectory: URI.file(path), name: path, description: undefined }); const sessionWorkspace = observableValue('workspace', upcastPartial({ folders: [folder('/work/other'), folder('/work/vscode')] })); From 71a9b8686a85a00fffa5d276dc7f0fee921b38b4 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sun, 15 Nov 2026 13:35:04 +0100 Subject: [PATCH 165/165] Worktree support: test that the session layer's source follows archiving and chats of a session it reads Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostWorktreeComposerFolders.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts index 047e4159659a90..3ac5db21dde329 100644 --- a/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts +++ b/src/vs/workbench/contrib/sessions/test/browser/agentsView/agentHostWorktreeComposerFolders.test.ts @@ -474,6 +474,33 @@ suite('AgentHostWorktreeComposerFolders', () => { assert.deepStrictEqual({ replaced, moved, chatAdded, staleMoved, archived: changes }, { replaced: 0, moved: 1, chatAdded: 2, staleMoved: 2, archived: 3 }); }); + test('the session layer\'s source tells as a session it reads is archived or unarchived, or gains or loses a chat that works elsewhere, without another event', () => { + const workspace = (path: string) => upcastPartial({ folders: [{ root: URI.file('/work/vscode'), workingDirectory: URI.file(path), name: 'vscode', description: undefined }] }); + const chat = (path: string) => upcastPartial({ resource: URI.from({ scheme: 'ahp-chat', path }), workspace: observableValue('workspace', workspace(`/work/vscode.worktrees${path}`)) }); + const first = chat('/first'); + const chats = observableValue('chats', [first]); + const isArchived = observableValue('archived', false); + const session = upcastPartial({ providerId: LOCAL_AGENT_HOST_PROVIDER_ID, isArchived, workspace: observableValue('workspace', workspace('/work/vscode')), chats }); + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(ISessionsManagementService, upcastPartial({ onDidChangeSession: Event.None, getAllSessions: () => [session], getSessionForChatResource: () => undefined })); + const source = store.add(instantiationService.createInstance(AgentHostWorktreeSessionLayerSource)); + const log: string[] = []; + const all = () => source.getWorkingDirectories().map(uri => uri.path).join(' '); + store.add(source.onDidChange(() => log.push(all()))); + + isArchived.set(true, undefined); + isArchived.set(false, undefined); + chats.set([first, chat('/second')], undefined); + chats.set([first], undefined); + + assert.deepStrictEqual(log, [ + '', + '/work/vscode /work/vscode.worktrees/first', + '/work/vscode /work/vscode.worktrees/first /work/vscode.worktrees/second', + '/work/vscode /work/vscode.worktrees/first', + ]); + }); + test('the session layer\'s source tells as a session comes to work in a new worktree in a folder other than its first', () => { const folder = (path: string) => ({ root: URI.file(path), workingDirectory: URI.file(path), name: path, description: undefined }); const sessionWorkspace = observableValue('workspace', upcastPartial({ folders: [folder('/work/other'), folder('/work/vscode')] }));