Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0c6eab1
Editor window: offer the worktrees that agent host sessions work in a…
sandy081 Nov 14, 2026
56a16a8
Workbench: a shown-session seam for chat view hosts, and shared folde…
sandy081 Nov 14, 2026
6ad6b5f
Chat view: report 'restore' only for the chat that the view restores,…
sandy081 Nov 14, 2026
ecd54bd
Worktree infra: wait for the sessions list before reading working dir…
sandy081 Nov 14, 2026
1272926
Worktree infra: describe worktrees by branch, compare real paths, and…
sandy081 Nov 14, 2026
039d452
Worktree infra: an open to restore the chat that a host shows already…
sandy081 Nov 14, 2026
6680d51
Worktree infra: report each explicit open of the chat a view shows, a…
sandy081 Nov 14, 2026
eb1c519
Worktree infra: wait for the local agent host sessions to resolve, al…
sandy081 Nov 14, 2026
259eb73
Worktree infra: report a first send's session as committed, also thro…
sandy081 Nov 14, 2026
ee64ec2
Worktree infra: test mocks of the Chat view report shown chats throug…
sandy081 Nov 14, 2026
20aa95f
Worktree infra: a worktree's admin back-link must name the checkout i…
sandy081 Nov 14, 2026
1fbcf1a
Worktree infra: tests reach the trust consumer through a refused move…
sandy081 Nov 14, 2026
787f246
Worktree infra: a Chat view load reports its chat itself, once, unles…
sandy081 Nov 14, 2026
38e75c3
Worktree infra: sources of where local agent host sessions work, besi…
sandy081 Nov 14, 2026
dd8a606
Worktree infra: the session that a started chat moves to after its fi…
sandy081 Nov 14, 2026
acf5dd8
Worktree infra: what a folder picker's folder and New Worktree do, to…
sandy081 Nov 14, 2026
6aa8ba0
Action list: a row with a description and a detail keeps its detail i…
sandy081 Nov 14, 2026
a82a018
Action list: a row with a detail is laid out alike with descriptions …
sandy081 Nov 14, 2026
84b9a4d
Revert the action list's detail row fixes, which the folder pickers a…
sandy081 Nov 14, 2026
ca9b1ea
Merge remote-tracking branch 'origin/main' into sandy081/agents/edito…
sandy081 Nov 14, 2026
25ab514
Merge remote-tracking branch 'origin/main' into sandy081/agents/edito…
sandy081 Nov 15, 2026
0e6ee63
Agent host: offer a worktree that sessions reach by several paths onc…
sandy081 Nov 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 { 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';
import { isDefined } from '../../../../../../base/common/types.js';
import { URI } from '../../../../../../base/common/uri.js';
import { FileOperationResult, IFileService, toFileOperationResult } from '../../../../../../platform/files/common/files.js';
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 { 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';

/**
* 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 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<string>();

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,
) {
super();
this._register(_agentSessionsService.model.onDidResolve(provider => {
if (isLocalAgentHostTarget(provider)) {
this._resolvedProviders.add(provider);
}
}));
}

async provideLocations(primary: URI, token: CancellationToken): Promise<IFolderLocation[]> {
const worktreesDir = await this._worktreesDir(primary);
if (!worktreesDir || token.isCancellationRequested) {
return [];
}
// 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;
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 [];
}
}
// 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>(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 {
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;
}
}));
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<IFolderLocation>(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<IFolderLocation | undefined> {
// 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
const worktreesDir = await this._worktreesDir(primary);
return worktreesDir && !token.isCancellationRequested ? this._resolve(worktreesDir, primary, location) : undefined;
}

/** 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<URI | undefined> {
if (primary.scheme !== Schemas.file) {
return undefined;
}
try {
const checkout = await readFolderGitCheckout(this._fileService, await this._real(primary));
// 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;
}
}

/**
* `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 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<IFolderLocation | undefined> {
if (location.scheme !== Schemas.file) {
return undefined;
}
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, realLocation, /^gitdir:\s*(?<path>.+?)\s*$/m);
if (!entry || !extUriBiasedIgnorePathCase.isEqual(dirname(await this._real(entry)), worktreesDir)) {
return undefined;
}
const backlink = await this._readLink(joinPath(entry, 'gitdir'), entry);
// 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
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<URI | undefined> {
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));
}

/** `resource` with its symbolic links resolved, or itself where it is not there. */
private async _real(resource: URI): Promise<URI> {
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<T>(read: () => Promise<T>): Promise<T | undefined> {
try {
return await read();
} catch (error) {
const result = error instanceof Error ? toFileOperationResult(error) : undefined;
if (result === FileOperationResult.FILE_NOT_FOUND || result === FileOperationResult.FILE_IS_DIRECTORY) {
return undefined;
}
throw error;
}
}
}

/** The branch a git HEAD names (`ref: refs/heads/<branch>`), 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\/(?<branch>.+)$/.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;
}
Original file line number Diff line number Diff line change
@@ -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<void>;
/** Fires as its sessions, or where they work, change. */
readonly onDidChange: Event<void>;
}

export const IAgentHostWorktreeSessionSources = createDecorator<IAgentHostWorktreeSessionSources>('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<void>;

/** 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<void>;
}

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<void>());
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<void> {
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);
2 changes: 2 additions & 0 deletions src/vs/workbench/contrib/chat/browser/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down
Loading