diff --git a/src/vs/base/common/network.ts b/src/vs/base/common/network.ts index 5cc244cc109669..871560d2ab0eb9 100644 --- a/src/vs/base/common/network.ts +++ b/src/vs/base/common/network.ts @@ -279,6 +279,7 @@ export const nodeModulesPath: AppResourcePath = 'vs/../../node_modules'; export const nodeModulesAsarPath: AppResourcePath = 'vs/../../node_modules.asar'; export const nodeModulesAsarUnpackedPath: AppResourcePath = 'vs/../../node_modules.asar.unpacked'; +export const AGENTS_AUTHORITY = 'agents'; export const VSCODE_AUTHORITY = 'vscode-app'; class FileAccessImpl { diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index c9736ff355fa22..15f0e809e4c554 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -96,7 +96,7 @@ import { NativeURLService } from '../../platform/url/common/urlService.js'; import { ElectronURLListener } from '../../platform/url/electron-main/electronUrlListener.js'; import { IWebviewManagerService } from '../../platform/webview/common/webviewManagerService.js'; import { WebviewMainService } from '../../platform/webview/electron-main/webviewMainService.js'; -import { isFolderToOpen, isWorkspaceToOpen, IWindowOpenable } from '../../platform/window/common/window.js'; +import { AgentsWindowOpenSource, isFolderToOpen, isWorkspaceToOpen, IWindowOpenable } from '../../platform/window/common/window.js'; import { getAllWindowsExcludingOffscreen, IWindowsMainService, OpenContext } from '../../platform/windows/electron-main/windows.js'; import { ICodeWindow } from '../../platform/window/electron-main/window.js'; import { WindowsMainService } from '../../platform/windows/electron-main/windowsMainService.js'; @@ -129,6 +129,7 @@ import { ipcUtilityProcessWorkerChannelName } from '../../platform/utilityProces import { ILocalPtyService, LocalReconnectConstants, TerminalIpcChannels, TerminalSettingId } from '../../platform/terminal/common/terminal.js'; import { ElectronPtyHostStarter } from '../../platform/terminal/electron-main/electronPtyHostStarter.js'; import { PtyHostService } from '../../platform/terminal/node/ptyHostService.js'; +import { parseExternalOpenSessionLinkUri } from '../../platform/agentHost/common/openSessionLink.js'; import { ElectronAgentHostStarter } from '../../platform/agentHost/electron-main/electronAgentHostStarter.js'; import { AgentHostProcessManager } from '../../platform/agentHost/node/agentHostService.js'; import { NODE_REMOTE_RESOURCE_CHANNEL_NAME, NODE_REMOTE_RESOURCE_IPC_METHOD_NAME, NodeRemoteResourceResponse, NodeRemoteResourceRouter } from '../../platform/remote/common/electronRemoteResources.js'; @@ -1050,6 +1051,15 @@ export class CodeApplication extends Disposable { private async handleProtocolUrl(windowsMainService: IWindowsMainService, dialogMainService: IDialogMainService, urlService: IURLService, uri: URI, options?: IOpenURLOptions): Promise { this.logService.trace('app#handleProtocolUrl():', uri.toString(true), options); + const agentSessionLink = parseExternalOpenSessionLinkUri(uri, this.productService.urlProtocol); + if (agentSessionLink) { + const windows = await windowsMainService.openAgentsWindow({ + context: OpenContext.LINK, + cli: { ...this.environmentMainService.args }, + }, undefined, agentSessionLink, AgentsWindowOpenSource.Link); + return windows.length > 0; + } + // Support 'workspace' URLs (https://github.com/microsoft/vscode/issues/124263) if (uri.scheme === this.productService.urlProtocol && uri.path === 'workspace') { uri = uri.with({ @@ -1497,6 +1507,17 @@ export class CodeApplication extends Disposable { // Then check for windows from protocol links to open if (initialProtocolUrls) { + const agentSessionProtocolUrlIndex = initialProtocolUrls.urls.findIndex(protocolUrl => + parseExternalOpenSessionLinkUri(protocolUrl.uri, this.productService.urlProtocol)); + if (agentSessionProtocolUrlIndex >= 0) { + const [agentSessionProtocolUrl] = initialProtocolUrls.urls.splice(agentSessionProtocolUrlIndex, 1); + const agentSessionLink = parseExternalOpenSessionLinkUri(agentSessionProtocolUrl.uri, this.productService.urlProtocol); + return windowsMainService.openAgentsWindow({ + context: OpenContext.LINK, + cli: args, + initialStartup: true, + }, undefined, agentSessionLink, AgentsWindowOpenSource.Link); + } // Openables can open as windows directly if (initialProtocolUrls.openables.length > 0) { diff --git a/src/vs/platform/agentHost/browser/agentHostConnectionsService.ts b/src/vs/platform/agentHost/browser/agentHostConnectionsService.ts index 757cdd288e2b0e..949cab4ed0ddac 100644 --- a/src/vs/platform/agentHost/browser/agentHostConnectionsService.ts +++ b/src/vs/platform/agentHost/browser/agentHostConnectionsService.ts @@ -9,7 +9,7 @@ import { localize } from '../../../nls.js'; import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; import { AgentSession } from '../common/agent.js'; import { IAgentConnection, IAgentHostService } from '../common/agentService.js'; -import { AMBIENT_AGENT_HOST_AUTHORITY, IAgentHostConnectionInfo, IAgentHostConnectionsService, IAgentHostSessionResolution, IAgentHostSessionResolutionPolicy, LOCAL_AGENT_HOST_SCHEME_PREFIX } from '../common/agentHostConnectionsService.js'; +import { AMBIENT_AGENT_HOST_AUTHORITY, IAgentHostConnectionInfo, IAgentHostConnectionsService, IAgentHostSessionIdentity, IAgentHostSessionResolution, IAgentHostSessionResolutionPolicy, LOCAL_AGENT_HOST_SCHEME_PREFIX } from '../common/agentHostConnectionsService.js'; import { findRemoteAgentHostSessionTypeAuthority, isRemoteAgentHostSessionType, remoteAgentHostSessionTypeAuthorityPrefix } from '../common/agentHostSessionType.js'; import { agentHostAuthority } from '../common/agentHostUri.js'; import { IRemoteAgentHostService } from '../common/remoteAgentHostService.js'; @@ -99,26 +99,38 @@ export class AgentHostConnectionsService extends Disposable implements IAgentHos } resolveSessionResource(sessionResource: URI): IAgentHostSessionResolution | undefined { + const identity = this.resolveSessionResourceIdentity(sessionResource); + if (!identity) { + return undefined; + } + const connection = this.getConnectionByAuthority(identity.connectionAuthority); + return connection ? { ...identity, connection } : undefined; + } + + resolveSessionResourceIdentity(sessionResource: URI): IAgentHostSessionIdentity | undefined { const scheme = sessionResource.scheme; const rawSessionId = sessionResource.path.substring(1); if (scheme.startsWith(LOCAL_AGENT_HOST_SCHEME_PREFIX)) { const provider = scheme.substring(LOCAL_AGENT_HOST_SCHEME_PREFIX.length); return provider - ? this._createSessionResolution(AMBIENT_AGENT_HOST_AUTHORITY, this._agentHostService, provider, rawSessionId) + ? this._createSessionIdentity(AMBIENT_AGENT_HOST_AUTHORITY, provider, rawSessionId) : undefined; } if (isRemoteAgentHostSessionType(scheme)) { // `remote--`: both segments may contain dashes, - // so resolve the authority against the live connection set (longest + // so resolve the authority against the known connection/policy set (longest // match wins) rather than splitting the string blindly. - const authority = findRemoteAgentHostSessionTypeAuthority(scheme, this.connections.filter(c => !c.isAmbient).map(c => c.authority)); + const authorities = new Set([ + ...this.connections.filter(c => !c.isAmbient).map(c => c.authority), + ...this._sessionResolutionPolicies.keys(), + ]); + const authority = findRemoteAgentHostSessionTypeAuthority(scheme, authorities); if (authority) { const provider = scheme.substring(remoteAgentHostSessionTypeAuthorityPrefix(authority).length); - const connection = this.getConnectionByAuthority(authority); - if (provider && connection) { - return this._createSessionResolution(authority, connection, provider, rawSessionId); + if (provider) { + return this._createSessionIdentity(authority, provider, rawSessionId); } } } @@ -126,12 +138,11 @@ export class AgentHostConnectionsService extends Disposable implements IAgentHos return undefined; } - private _createSessionResolution(authority: string, connection: IAgentConnection, provider: string, rawSessionId: string): IAgentHostSessionResolution { + private _createSessionIdentity(authority: string, provider: string, rawSessionId: string): IAgentHostSessionIdentity { const policy = this._sessionResolutionPolicies.get(authority); const alias = policy?.sessionSchemeAlias; const backendProvider = alias?.ui === provider ? alias.backend : provider; return { - connection, connectionAuthority: authority, backendSession: AgentSession.uri(backendProvider, rawSessionId), defaultChangesetKind: policy?.defaultChangesetKind, diff --git a/src/vs/platform/agentHost/common/agentHostConnectionsService.ts b/src/vs/platform/agentHost/common/agentHostConnectionsService.ts index 8fc06755e59998..288a8ca0e5199e 100644 --- a/src/vs/platform/agentHost/common/agentHostConnectionsService.ts +++ b/src/vs/platform/agentHost/common/agentHostConnectionsService.ts @@ -62,13 +62,16 @@ export interface IAgentHostConnectionInfo { * the owning {@link IAgentConnection}, its authority, and the canonical backend * agent-session URI used for protocol operations on that connection. */ -export interface IAgentHostSessionResolution { - readonly connection: IAgentConnection; +export interface IAgentHostSessionIdentity { readonly connectionAuthority: string; readonly backendSession: URI; readonly defaultChangesetKind?: DefaultChangesetKind; } +export interface IAgentHostSessionResolution extends IAgentHostSessionIdentity { + readonly connection: IAgentConnection; +} + /** Provider-owned policy needed to resolve a workbench session resource back to its host. */ export interface IAgentHostSessionResolutionPolicy { readonly sessionSchemeAlias?: IAgentHostSessionSchemeAlias; @@ -137,6 +140,12 @@ export interface IAgentHostConnectionsService { */ registerSessionResolutionPolicy(authority: string, policy: IAgentHostSessionResolutionPolicy): IDisposable; + /** + * Resolves an agent-host chat-session resource to its connection authority + * and backend session URI without requiring the host to be connected. + */ + resolveSessionResourceIdentity(sessionResource: URI): IAgentHostSessionIdentity | undefined; + /** * Resolves an agent-host chat-session resource to its owning connection and * backend session URI. Handles both local schemes diff --git a/src/vs/platform/agentHost/common/openSessionLink.ts b/src/vs/platform/agentHost/common/openSessionLink.ts index 449d08457e3307..49aebec7841eb5 100644 --- a/src/vs/platform/agentHost/common/openSessionLink.ts +++ b/src/vs/platform/agentHost/common/openSessionLink.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { AGENTS_AUTHORITY } from '../../../base/common/network.js'; import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { ILinkPresentation, ILinkPresentationStatus } from '../../dataChannel/common/dataChannel.js'; @@ -25,6 +26,9 @@ export const AGENT_HOST_SESSION_LINK_PATTERN = /^agent-host-session:\/\/[^/?#]+\ export const AGENT_HOST_SESSION_ONLY_LINK_PATTERN = /^(?![^#]*[?&]chat=)agent-host-session:\/\/[^/?#]+\/[^?#]+(?:\?[^#]*)?(?:#.*)?$/i; export const AGENT_HOST_CHAT_LINK_PATTERN = /^(?=[^#]*[?&]chat=)agent-host-session:\/\/[^/?#]+\/[^?#]+(?:\?[^#]*)?(?:#.*)?$/i; +const AGENT_HOST_SESSION_LINK_PATH_PREFIX = `/${AGENT_HOST_SESSION_LINK_SCHEME}/`; +const AGENT_HOST_CHAT_LINK_PATH_SEGMENT = '/chat/'; + export type AgentSessionLinkStatus = 'untitled' | 'inProgress' | 'needsInput' | 'completed' | 'error'; export function buildAgentSessionLinkPresentation(title: string, description: string | undefined, status: AgentSessionLinkStatus, kind: 'session' | 'chat' = 'session'): ILinkPresentation { @@ -103,6 +107,63 @@ export function buildOpenSessionLinkUri(backendSession: URI | string, chatId?: s return query.length > 0 ? `${base}?${query.join('&')}` : base; } +/** + * Builds a product protocol URL that opens an agent-host session in the Agents window. + * + * Shape: `://agents/agent-host-session//[/chat/]`. + */ +export function buildExternalOpenSessionLinkUri(productUrlProtocol: string, backendSession: URI | string, chatId?: string, turnId?: string): string { + const sessionLink = buildOpenSessionLinkUri(backendSession); + const encodedTarget = sessionLink.slice(`${AGENT_HOST_SESSION_LINK_SCHEME}://`.length); + const chatPath = chatId && chatId !== DEFAULT_CHAT_ID ? `${AGENT_HOST_CHAT_LINK_PATH_SEGMENT}${encodeURIComponent(encodeURIComponent(chatId))}` : ''; + const query = turnId ? `?turn=${encodeURIComponent(turnId)}` : ''; + return `${productUrlProtocol}://${AGENTS_AUTHORITY}${AGENT_HOST_SESSION_LINK_PATH_PREFIX}${encodedTarget}${chatPath}${query}`; +} + +/** + * Recovers the internal agent-host session link carried by an Agents product protocol URL. + */ +export function parseExternalOpenSessionLinkUri(uri: URI | string, productUrlProtocol: string): URI | undefined { + const parsed = typeof uri === 'string' ? URI.parse(uri) : uri; + if (parsed.scheme !== productUrlProtocol || parsed.authority !== AGENTS_AUTHORITY || !parsed.path.startsWith(AGENT_HOST_SESSION_LINK_PATH_PREFIX)) { + return undefined; + } + + const sessionPath = parsed.path.slice(AGENT_HOST_SESSION_LINK_PATH_PREFIX.length); + const providerEnd = sessionPath.indexOf('/'); + if (providerEnd <= 0 || providerEnd === sessionPath.length - 1) { + return undefined; + } + + const chatSegmentIndex = sessionPath.lastIndexOf(AGENT_HOST_CHAT_LINK_PATH_SEGMENT); + const hasChatSegment = chatSegmentIndex > providerEnd; + let chatId: string | undefined; + if (hasChatSegment) { + const encodedChatId = sessionPath.slice(chatSegmentIndex + AGENT_HOST_CHAT_LINK_PATH_SEGMENT.length); + if (!encodedChatId) { + return undefined; + } + try { + chatId = decodeURIComponent(encodedChatId); + } catch { + return undefined; + } + } + const sessionTarget = hasChatSegment ? sessionPath.slice(0, chatSegmentIndex) : sessionPath; + const query = [ + chatId ? `chat=${encodeURIComponent(chatId)}` : undefined, + parsed.query, + ].filter(queryPart => !!queryPart).join('&'); + const sessionLink = URI.from({ + scheme: AGENT_HOST_SESSION_LINK_SCHEME, + authority: sessionTarget.slice(0, providerEnd), + path: sessionTarget.slice(providerEnd), + query, + fragment: parsed.fragment, + }); + return parseOpenSessionLinkUri(sessionLink) ? sessionLink : undefined; +} + /** * Recovers the backend session URI from an {@link AGENT_HOST_SESSION_LINK_SCHEME} * link, or `undefined` when the URI is not such a link. diff --git a/src/vs/platform/agentHost/test/browser/agentHostConnectionsService.test.ts b/src/vs/platform/agentHost/test/browser/agentHostConnectionsService.test.ts index b05643e25fbcb9..a77492ac98246c 100644 --- a/src/vs/platform/agentHost/test/browser/agentHostConnectionsService.test.ts +++ b/src/vs/platform/agentHost/test/browser/agentHostConnectionsService.test.ts @@ -149,4 +149,31 @@ suite('AgentHostConnectionsService', () => { resolutionChanges: 2, }); }); + + test('resolves remote session identity while disconnected', () => { + const { service } = createService([info('myhost', 'My Remote')], new Map()); + store.add(service.registerSessionResolutionPolicy('myhost', { + sessionSchemeAlias: { ui: 'copilot', backend: 'ahp-session' }, + defaultChangesetKind: ChangesetKind.Session, + })); + + const resource = URI.parse('remote-myhost-copilot:/xyz789'); + const identity = service.resolveSessionResourceIdentity(resource); + + assert.deepStrictEqual({ + identity: identity && { + connectionAuthority: identity.connectionAuthority, + backendSession: identity.backendSession.toString(), + defaultChangesetKind: identity.defaultChangesetKind, + }, + resolution: service.resolveSessionResource(resource), + }, { + identity: { + connectionAuthority: 'myhost', + backendSession: 'ahp-session:/xyz789', + defaultChangesetKind: ChangesetKind.Session, + }, + resolution: undefined, + }); + }); }); diff --git a/src/vs/platform/agentHost/test/common/openSessionLink.test.ts b/src/vs/platform/agentHost/test/common/openSessionLink.test.ts index c6952a30cfaa18..4e2790738eac26 100644 --- a/src/vs/platform/agentHost/test/common/openSessionLink.test.ts +++ b/src/vs/platform/agentHost/test/common/openSessionLink.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { AGENT_HOST_CHAT_LINK_PATTERN, AGENT_HOST_SESSION_ONLY_LINK_PATTERN, buildAgentSessionLinkPresentation, buildOpenSessionLinkForChatResource, buildOpenSessionLinkUri, isCreateChatTool, isCreateSessionTool, isSendMessageTool, parseOpenSessionLinkChatId, parseOpenSessionLinkTurnId, parseOpenSessionLinkUri } from '../../common/openSessionLink.js'; +import { AGENT_HOST_CHAT_LINK_PATTERN, AGENT_HOST_SESSION_ONLY_LINK_PATTERN, buildAgentSessionLinkPresentation, buildExternalOpenSessionLinkUri, buildOpenSessionLinkForChatResource, buildOpenSessionLinkUri, isCreateChatTool, isCreateSessionTool, isSendMessageTool, parseExternalOpenSessionLinkUri, parseOpenSessionLinkChatId, parseOpenSessionLinkTurnId, parseOpenSessionLinkUri } from '../../common/openSessionLink.js'; import { buildChatUri, buildDefaultChatUri } from '../../common/state/sessionState.js'; suite('openSessionLink', () => { @@ -41,6 +41,55 @@ suite('openSessionLink', () => { assert.strictEqual(parsed?.toString(), URI.parse(backend).toString()); }); + test('builds and parses an external Agents window session link', () => { + const external = buildExternalOpenSessionLinkUri('vscode-insiders', 'copilotcli:/abc-123', 'chat-9', 'turn-7'); + assert.deepStrictEqual({ + external, + internal: parseExternalOpenSessionLinkUri(external, 'vscode-insiders')?.toString(true), + }, { + external: 'vscode-insiders://agents/agent-host-session/copilotcli/abc-123/chat/chat-9?turn=turn-7', + internal: 'agent-host-session://copilotcli/abc-123?chat=chat-9&turn=turn-7', + }); + }); + + test('encodes chat ids as path segments in external links', () => { + const external = buildExternalOpenSessionLinkUri('vscode-insiders', 'copilotcli:/abc-123', 'chat/9'); + const internal = parseExternalOpenSessionLinkUri(external, 'vscode-insiders'); + + assert.deepStrictEqual({ + external, + chatId: internal && parseOpenSessionLinkChatId(internal), + }, { + external: 'vscode-insiders://agents/agent-host-session/copilotcli/abc-123/chat/chat%252F9', + chatId: 'chat/9', + }); + }); + + test('preserves percent escapes in opaque session ids', () => { + const backend = URI.from({ scheme: 'copilotcli', path: '/abc%2Fdef' }); + const external = buildExternalOpenSessionLinkUri('vscode-insiders', backend); + const internal = parseExternalOpenSessionLinkUri(external, 'vscode-insiders'); + + assert.deepStrictEqual({ + external, + backend: internal && parseOpenSessionLinkUri(internal)?.toString(), + }, { + external: 'vscode-insiders://agents/agent-host-session/copilotcli/abc%252Fdef', + backend: 'copilotcli:/abc%252Fdef', + }); + }); + + test('rejects invalid external Agents window session links', () => { + assert.deepStrictEqual([ + parseExternalOpenSessionLinkUri('vscode://agents/agent-host-session/copilotcli/abc-123', 'vscode-insiders'), + parseExternalOpenSessionLinkUri('vscode-insiders://extensions/agent-host-session/copilotcli/abc-123', 'vscode-insiders'), + parseExternalOpenSessionLinkUri('vscode-insiders://agents/session/copilotcli/abc-123', 'vscode-insiders'), + parseExternalOpenSessionLinkUri('vscode-insiders://agents/agent-host-session/copilotcli', 'vscode-insiders'), + parseExternalOpenSessionLinkUri('vscode-insiders://agents/agent-host-session//abc-123', 'vscode-insiders'), + parseExternalOpenSessionLinkUri('vscode-insiders://agents/agent-host-session/copilotcli/abc-123/chat/', 'vscode-insiders'), + ], [undefined, undefined, undefined, undefined, undefined, undefined]); + }); + test('carries an optional chat id', () => { const link = buildOpenSessionLinkUri('copilotcli:/abc-123', 'chat-9'); assert.strictEqual(link, 'agent-host-session://copilotcli/abc-123?chat=chat-9'); diff --git a/src/vs/platform/window/common/window.ts b/src/vs/platform/window/common/window.ts index d073d011d56c24..357bf62d065e64 100644 --- a/src/vs/platform/window/common/window.ts +++ b/src/vs/platform/window/common/window.ts @@ -112,6 +112,7 @@ export const enum AgentsWindowOpenSource { ChatHandoff = 'chatHandoff', Banner = 'banner', CommandLine = 'commandLine', + Link = 'link', Unknown = 'unknown', } @@ -124,6 +125,7 @@ export function isAgentsWindowOpenSource(value: unknown): value is AgentsWindowO case AgentsWindowOpenSource.ChatHandoff: case AgentsWindowOpenSource.Banner: case AgentsWindowOpenSource.CommandLine: + case AgentsWindowOpenSource.Link: case AgentsWindowOpenSource.Unknown: return true; default: diff --git a/src/vs/sessions/browser/parts/chatCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts index dd4f26558ca1e7..a1d5e7ac8226ad 100644 --- a/src/vs/sessions/browser/parts/chatCompositeBar.ts +++ b/src/vs/sessions/browser/parts/chatCompositeBar.ts @@ -14,7 +14,7 @@ import { ScrollbarVisibility } from '../../../base/common/scrollable.js'; import { autorun, IObservable } from '../../../base/common/observable.js'; import { isLinux } from '../../../base/common/platform.js'; import { IThemeService } from '../../../platform/theme/common/themeService.js'; -import { Action } from '../../../base/common/actions.js'; +import { Action, Separator } from '../../../base/common/actions.js'; import { InputBox } from '../../../base/browser/ui/inputbox/inputBox.js'; import { defaultInputBoxStyles } from '../../../platform/theme/browser/defaultStyles.js'; import { Codicon } from '../../../base/common/codicons.js'; @@ -37,7 +37,7 @@ import { applySessionBarThemeColors } from './sessionBarStyles.js'; import { ISessionsProvidersService } from '../../services/sessions/browser/sessionsProvidersService.js'; import { isAgentHostProvider } from '../../common/agentHostSessionsProvider.js'; import { ICommandService } from '../../../platform/commands/common/commands.js'; -import { CLOSE_CHAT_COMMAND_ID } from '../../common/sessionCommands.js'; +import { CLOSE_CHAT_COMMAND_ID, COPY_AGENT_HOST_CHAT_LINK_COMMAND_ID } from '../../common/sessionCommands.js'; import { getSessionConversationStatusAriaLabel } from '../sessionConversationGroups.js'; import { IEditorGroupsService } from '../../../workbench/services/editor/common/editorGroupsService.js'; @@ -474,6 +474,12 @@ export class ChatCompositeBar extends Disposable { this._startTabEditing(chatTab); })); + const copyLinkAction = this._tabDisposables.add(new Action(COPY_AGENT_HOST_CHAT_LINK_COMMAND_ID, localize('copyChatLink', "Copy Link"), undefined, true, async () => { + if (session) { + await this._commandService.executeCommand(COPY_AGENT_HOST_CHAT_LINK_COMMAND_ID, { session, chat }); + } + })); + // Delete permanently removes the chat (destructive). Only non-main chats // can be deleted; the main chat lives and dies with its session. const deleteAction = this._tabDisposables.add(new Action('sessionCompositeBar.deleteChat', localize('deleteChat', "Delete Chat"), undefined, true, async () => { @@ -505,14 +511,12 @@ export class ChatCompositeBar extends Disposable { getAnchor: () => event, getActions: () => { const capabilities = getChatCapabilities(chat, session, undefined); - const actions = []; - if (capabilities.canRename) { - actions.push(renameAction); - } - if (capabilities.canDelete) { - actions.push(deleteAction); - } - return actions; + const provider = session && this._sessionsProvidersService.getProvider(session.providerId); + return Separator.join( + capabilities.canRename ? [renameAction] : [], + provider && isAgentHostProvider(provider) ? [copyLinkAction] : [], + capabilities.canDelete ? [deleteAction] : [], + ); } }); })); diff --git a/src/vs/sessions/common/sessionCommands.ts b/src/vs/sessions/common/sessionCommands.ts index 8d3af813129950..0be8bc91e9b9eb 100644 --- a/src/vs/sessions/common/sessionCommands.ts +++ b/src/vs/sessions/common/sessionCommands.ts @@ -31,6 +31,12 @@ export const MARK_SESSION_UNREAD_COMMAND_ID = 'sessionsViewPane.markUnread'; /** Closes a chat tab. Registered in `sessionsActions.ts`. */ export const CLOSE_CHAT_COMMAND_ID = 'sessions.chatCompositeBar.closeChat'; +/** Copies a browser link to an Agent Host session. Registered in `agentHostSessionBranchActions.ts`. */ +export const COPY_AGENT_HOST_SESSION_LINK_COMMAND_ID = 'sessions.copyAgentHostSessionLink'; + +/** Copies a browser link to an Agent Host chat. Registered in `agentHostSessionBranchActions.ts`. */ +export const COPY_AGENT_HOST_CHAT_LINK_COMMAND_ID = 'sessions.copyAgentHostChatLink'; + /** Focuses the active session. Registered in `sessionsActions.ts`. */ export const FOCUS_ACTIVE_SESSION_COMMAND_ID = 'sessions.focusActiveSession'; diff --git a/src/vs/sessions/contrib/chat/browser/openSessionLinkOpener.contribution.ts b/src/vs/sessions/contrib/chat/browser/openSessionLinkOpener.contribution.ts index 6b69378d442277..095d4fe0c6b679 100644 --- a/src/vs/sessions/contrib/chat/browser/openSessionLinkOpener.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/openSessionLinkOpener.contribution.ts @@ -29,7 +29,7 @@ import { getSessionSummaryHoverData } from '../../sessions/browser/sessionHoverC * session and opening it through {@link ISessionsService}. The link carries the * backend session URI; the owning session in the window uses a client scheme * (e.g. `agent-host-copilotcli`), so matching goes through - * {@link IAgentHostConnectionsService.resolveSessionResource}. When the link + * {@link IAgentHostConnectionsService.resolveSessionResourceIdentity}. When the link * carries a chat id (from `create_chat`), that specific peer chat is opened; * otherwise the session's main/default chat is opened, via * {@link ISessionsService.openChat} in both cases so the correct chat becomes @@ -81,7 +81,7 @@ export class OpenSessionLinkOpenerContribution extends Disposable implements IWo private _findSessionForLink(resource: URI | string): ISession | undefined { const backendSession = parseOpenSessionLinkUri(resource); return backendSession - ? findSession(backendSession, this._sessionsManagementService, this._connectionsService) + ? findSessionForOpenSessionLink(backendSession, this._sessionsManagementService, this._connectionsService) : undefined; } @@ -116,7 +116,7 @@ class AgentSessionLinkPresentationWatcher extends Disposable implements ILinkPre reader => { sessionsChanged.read(reader); const session = backendSession - ? findSession(backendSession, sessionsManagementService, connectionsService) + ? findSessionForOpenSessionLink(backendSession, sessionsManagementService, connectionsService) : undefined; return session ? readSessionState(session, chatId, reader, kind) : undefined; }, @@ -154,15 +154,17 @@ export interface ISessionLinkState { readonly chats: IObservable; } -function findSession( +export function findSessionForOpenSessionLink( backendSession: URI, sessionsManagementService: ISessionsManagementService, connectionsService: IAgentHostConnectionsService, ): ISession | undefined { return sessionsManagementService.getSessions().find(session => { - const resolved = connectionsService.resolveSessionResource(session.resource); - return isEqual(session.resource, backendSession) - || !!resolved && isEqual(resolved.backendSession, backendSession); + if (isEqual(session.resource, backendSession)) { + return true; + } + const identity = connectionsService.resolveSessionResourceIdentity(session.resource); + return !!identity && isEqual(identity.backendSession, backendSession); }); } diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index a694beac78ed33..7c34a645a928ae 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -67,7 +67,8 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.backgroundActivities', "Press Shift+Tab from the chat input to reach metadata and status pills above it, then press Enter or Space to activate a pill. Live browsers appear in their own pill, and the chat's subagents of any status appear in another. A pill with more than one entry opens a picker; use the up and down arrows to navigate, Enter to open an entry, and Escape to dismiss the picker and return focus to the pill.")); content.push(localize('sessionsChat.conversations', "When multiple chats appear as tabs in a single group, the tab row replaces the session header and includes the session actions. Side-by-side chat groups retain the session header and keep their tab rows compact.")); content.push(localize('sessionsChat.sessionsListChats', "Sessions with multiple user-facing chats show those chats nested beneath the session in the Sessions list. Use the arrow keys to navigate the list and Enter to open a chat. Side chats and subagent chats are omitted from this nested list: side chats are reachable from the Side Chats dropdown in the session's overflow menu, and subagent chats open from their pills in the chat transcript.")); - content.push(localize('sessionsChat.sessionsListChatContextMenu', "Open a nested chat's context menu to rename it, open it to the side, or, when supported, permanently delete it.")); + content.push(localize('sessionsChat.sessionsListChatContextMenu', "Open a nested chat's context menu to rename it, open it to the side, or, when supported, permanently delete it. Agent Host chats also offer Copy Link.")); + content.push(localize('sessionsChat.copySessionLink', "To copy a browser link that opens an Agent Host session in the Agents window, open the session's context menu and choose Copy Link.")); content.push(localize('sessionsChat.subagentPills', "Subagent pills in the chat transcript can be dragged to a chat group's edge to open the subagent beside the current chat. With the keyboard, focus a subagent pill and press Alt+Enter to open it beside the current chat.")); content.push(localize('sessionsChat.chatGroups', "Chats can be arranged in groups. Focus the previous group{0} or next group{1}. Split the active chat into a group to the right{2} or below{3}, or move it to the previous group{4} or next group{5}.", ``, ``, ``, ``, ``, ``)); content.push(localize('sessionsChat.closeChat', "Activate a chat tab's close button to close (hide) that chat from the tab strip without deleting it; reopen it later from the Chats menu. The session's main chat cannot be closed.")); diff --git a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts index 4a4f1ab768ed9a..3776129cad9dd6 100644 --- a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts @@ -6,8 +6,11 @@ import { ipcRenderer } from '../../../../base/parts/sandbox/electron-browser/globals.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IAgentHostByokLmHandler } from '../../../../platform/agentHost/common/agentHostByokLm.js'; +import { IAgentHostConnectionsService } from '../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { buildExternalOpenSessionLinkUri, parseOpenSessionLinkChatId, parseOpenSessionLinkTurnId, parseOpenSessionLinkUri } from '../../../../platform/agentHost/common/openSessionLink.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { AgentHostByokLmHandler } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostByokLmHandler.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; @@ -17,6 +20,8 @@ import { ISessionsProvidersService } from '../../../services/sessions/browser/se import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { ILifecycleService, LifecyclePhase } from '../../../../workbench/services/lifecycle/common/lifecycle.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; import { SessionsView, SessionsViewId as SessionsListViewId } from '../../sessions/browser/views/sessionsView.js'; import { ISessionsSetUpService } from '../../../browser/sessionsSetUpService.js'; import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js'; @@ -25,10 +30,12 @@ import { SessionsCopilotConfigSlashSubmitHandlerContribution } from '../browser/ import { AgentsWindowOpenSource, isAgentsWindowOpenSource } from '../../../../platform/window/common/window.js'; import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; import { TOTAL_SESSIONS_KEY } from '../../sessions/browser/sessionsLifecycleTracker.js'; import { ISessionsWindowOpenViewState, SessionsWindowOpenTelemetry, SessionsWindowSessionStartTelemetry } from '../../sessions/browser/sessionsWindowOpenTelemetry.js'; import { INewSessionComposerService, NewSessionWorkspacePreselectionSource } from '../browser/newSessionComposerService.js'; import { resolveAgentsWindowFolderIntent } from '../browser/agentsWindowOpenIntent.js'; +import { findSessionForOpenSessionLink } from '../browser/openSessionLinkOpener.contribution.js'; class SelectAgentsFolderContribution extends Disposable implements IWorkbenchContribution { @@ -49,6 +56,9 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon @ITelemetryService private readonly telemetryService: ITelemetryService, @INewSessionComposerService private readonly newSessionComposerService: INewSessionComposerService, @IConfigurationService private readonly configurationService: IConfigurationService, + @IAgentHostConnectionsService private readonly agentHostConnectionsService: IAgentHostConnectionsService, + @INotificationService private readonly notificationService: INotificationService, + @IProductService private readonly productService: IProductService, ) { super(); const handleSelectAgentsFolder = (_: unknown, ...args: unknown[]) => { @@ -139,6 +149,12 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon await this.lifecycleService.when(LifecyclePhase.Eventually); this.logService.info('[AgentsHandoff] reached LifecyclePhase.Eventually'); + const backendSession = parseOpenSessionLinkUri(sessionResource); + if (backendSession) { + await this.sessionsPartService.getProgressIndicator().showWhile(this.resolveAndOpenSessionLink(sessionResource, backendSession)); + return; + } + // Fast path — already on the target session. const current = this.sessionsService.activeSession.get(); if (current && current.resource.toString() === sessionResource.toString()) { @@ -152,6 +168,63 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon await this.sessionsPartService.getProgressIndicator().showWhile(this.resolveAndOpenSession(sessionResource)); } + private async resolveAndOpenSessionLink(sessionLink: URI, backendSession: URI): Promise { + const session = await this.waitForSessionLinkAvailable(backendSession); + if (!session) { + this.logService.warn('[AgentsHandoff] linked session never appeared in providers; aborting'); + const externalLink = buildExternalOpenSessionLinkUri( + this.productService.urlProtocol, + backendSession, + parseOpenSessionLinkChatId(sessionLink), + parseOpenSessionLinkTurnId(sessionLink), + ); + this.notificationService.error(localize('agentsHandoff.sessionNotFound', "The linked session could not be found: {0}", externalLink)); + return; + } + + const provider = this.sessionsProvidersService.getProvider(session.providerId); + if (provider && isAgentHostProvider(provider) && provider.connect && !this.agentHostConnectionsService.resolveSessionResource(session.resource)) { + try { + await provider.connect(); + } catch (error) { + // Still reveal the seeded session so its connection recovery UI can surface the failure. + this.logService.warn('[AgentsHandoff] linked session provider failed to connect on demand', error); + } + } + + const chatId = parseOpenSessionLinkChatId(sessionLink); + const chatResource = chatId ? session.resource.with({ fragment: chatId }) : session.mainChat.get().resource; + this.logService.info(`[AgentsHandoff] linked session available; opening ${chatResource.toString()}`); + await this.sessionsService.openChat(session, chatResource, { source: 'link' }); + } + + private waitForSessionLinkAvailable(backendSession: URI, timeoutMs = 15_000): Promise> { + const findSession = () => findSessionForOpenSessionLink(backendSession, this.sessionsManagementService, this.agentHostConnectionsService); + const existing = findSession(); + if (existing) { + return Promise.resolve(existing); + } + + return new Promise(resolve => { + const store = new DisposableStore(); + const done = (session: ReturnType) => { + store.dispose(); + resolve(session); + }; + const tryFind = () => { + const session = findSession(); + if (session) { + done(session); + } + }; + const timer = setTimeout(() => done(findSession()), timeoutMs); + store.add({ dispose: () => clearTimeout(timer) }); + store.add(this.sessionsManagementService.onDidChangeSessions(tryFind)); + store.add(this.agentHostConnectionsService.onDidChangeSessionResolution(tryFind)); + tryFind(); + }); + } + private async resolveAndOpenSession(sessionResource: URI): Promise { // The Copilot Chat Sessions Provider lists sessions asynchronously // via an RPC; the target session may not yet be in the providers' 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 54624aa9326337..9a5663f7abe2ef 100644 --- a/src/vs/sessions/contrib/chat/test/browser/openSessionLinkOpener.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/openSessionLinkOpener.test.ts @@ -23,7 +23,7 @@ import { ISessionsService } from '../../../../services/sessions/browser/sessions import { IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; -import { ISessionLinkChatState, ISessionLinkState, OpenSessionLinkOpenerContribution, readSessionState } from '../../browser/openSessionLinkOpener.contribution.js'; +import { findSessionForOpenSessionLink, ISessionLinkChatState, ISessionLinkState, OpenSessionLinkOpenerContribution, readSessionState } from '../../browser/openSessionLinkOpener.contribution.js'; suite('OpenSessionLinkOpenerContribution', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -70,7 +70,8 @@ suite('OpenSessionLinkOpenerContribution', () => { } }; const connectionsService = new class extends mock() { - override resolveSessionResource() { + override readonly onDidChangeSessionResolution = Event.None; + override resolveSessionResourceIdentity() { return undefined; } }; @@ -138,7 +139,8 @@ suite('OpenSessionLinkOpenerContribution', () => { } }; const connectionsService = new class extends mock() { - override resolveSessionResource() { + override readonly onDidChangeSessionResolution = Event.None; + override resolveSessionResourceIdentity() { return undefined; } }; @@ -170,6 +172,23 @@ suite('OpenSessionLinkOpenerContribution', () => { assert.deepStrictEqual({ result, opened }, { result: true, opened: ['chat:copilotcli:/session-1'] }); }); + test('finds a client session from its backend link resource', () => { + const backendSession = URI.parse('copilotcli:/session-1'); + const session = upcastPartial({ resource: URI.parse('agent-host-copilotcli:/session-1') }); + const sessionsManagementService = new class extends mock() { + override getSessions(): ISession[] { + return [session]; + } + }; + const connectionsService = new class extends mock() { + override resolveSessionResourceIdentity() { + return upcastPartial>>({ backendSession }); + } + }; + + assert.strictEqual(findSessionForOpenSessionLink(backendSession, sessionsManagementService, connectionsService), session); + }); + test('uses a contextual placeholder without opening the linked chat', () => { const sessionResource = URI.parse('copilotcli:/session-1'); const chatResource = sessionResource.with({ fragment: 'chat-2' }); @@ -208,7 +227,7 @@ suite('OpenSessionLinkOpenerContribution', () => { sessionsManagementService, new class extends mock() { }, new class extends mock() { - override resolveSessionResource() { return undefined; } + override resolveSessionResourceIdentity() { return undefined; } }, linkPresentationService, sessionsProvidersService, @@ -271,7 +290,7 @@ suite('OpenSessionLinkOpenerContribution', () => { sessionsManagementService, new class extends mock() { }, new class extends mock() { - override resolveSessionResource() { return undefined; } + override resolveSessionResourceIdentity() { return undefined; } }, new class extends mock() { override registerLinkPresentationProvider(): IDisposable { return Disposable.None; } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionBranchActions.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionBranchActions.ts index 6a7428f7c4763a..dc364a5ada9ba4 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionBranchActions.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionBranchActions.ts @@ -8,16 +8,88 @@ import { Action2, registerAction2 } from '../../../../../platform/actions/common import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { buildExternalOpenSessionLinkUri } from '../../../../../platform/agentHost/common/openSessionLink.js'; import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../../common/agentHostSessionsProvider.js'; import { SessionProviderIdContext } from '../../../../common/contextkeys.js'; -import { ISession } from '../../../../services/sessions/common/session.js'; +import { COPY_AGENT_HOST_CHAT_LINK_COMMAND_ID, COPY_AGENT_HOST_SESSION_LINK_COMMAND_ID } from '../../../../common/sessionCommands.js'; +import { IChat, ISession } from '../../../../services/sessions/common/session.js'; +import { Menus } from '../../../../browser/menus.js'; import { SessionItemContextMenuId, SessionItemHasBranchNameContext } from '../../../sessions/browser/views/sessionsList.js'; +interface ISessionChatLinkContext { + readonly session: ISession; + readonly chat: IChat; +} + +function getSession(context: ISession | ISession[] | undefined): ISession | undefined { + return Array.isArray(context) ? context[0] : context; +} + +function buildExternalLink(accessor: ServicesAccessor, session: ISession, chatId?: string): string { + const identity = accessor.get(IAgentHostConnectionsService).resolveSessionResourceIdentity(session.resource); + if (!identity) { + throw new Error(`Cannot resolve Agent Host session resource ${session.resource.toString()}`); + } + return buildExternalOpenSessionLinkUri(accessor.get(IProductService).urlProtocol, identity.backendSession, chatId); +} + +registerAction2(class CopyAgentHostSessionLinkAction extends Action2 { + constructor() { + super({ + id: COPY_AGENT_HOST_SESSION_LINK_COMMAND_ID, + title: localize2('copyAgentHostSessionLink', "Copy Link"), + menu: [{ + id: SessionItemContextMenuId, + group: '2_open', + order: 2, + when: ContextKeyExpr.regex(SessionProviderIdContext.key, ANY_AGENT_HOST_PROVIDER_RE), + }, { + id: Menus.SessionHeaderContext, + group: '2_edit', + order: 2, + when: ContextKeyExpr.regex(SessionProviderIdContext.key, ANY_AGENT_HOST_PROVIDER_RE), + }], + }); + } + + async run(accessor: ServicesAccessor, context?: ISession | ISession[]): Promise { + const session = getSession(context); + if (!session) { + return; + } + await accessor.get(IClipboardService).writeText(buildExternalLink(accessor, session)); + } +}); + +registerAction2(class CopyAgentHostChatLinkAction extends Action2 { + constructor() { + super({ + id: COPY_AGENT_HOST_CHAT_LINK_COMMAND_ID, + title: localize2('copyAgentHostChatLink', "Copy Link"), + menu: [{ + id: Menus.SessionChatItemContext, + group: '2_copy', + order: 1, + when: ContextKeyExpr.regex(SessionProviderIdContext.key, ANY_AGENT_HOST_PROVIDER_RE), + }], + }); + } + + async run(accessor: ServicesAccessor, context?: ISessionChatLinkContext): Promise { + if (!context) { + return; + } + await accessor.get(IClipboardService).writeText(buildExternalLink(accessor, context.session, context.chat.resource.fragment || undefined)); + } +}); + registerAction2(class CopySessionBranchNameAction extends Action2 { constructor() { super({ id: 'sessionsViewPane.agentHost.copySessionBranchName', - title: localize2('copySessionBranchName', "Copy Session Branch Name"), + title: localize2('copySessionBranchName', "Copy Branch Name"), menu: [{ id: SessionItemContextMenuId, group: '2_open', @@ -31,7 +103,7 @@ registerAction2(class CopySessionBranchNameAction extends Action2 { } async run(accessor: ServicesAccessor, context?: ISession | ISession[]): Promise { - const session = Array.isArray(context) ? context[0] : context; + const session = getSession(context); const branchName = session?.workspace.get()?.folders[0]?.gitRepository?.branchName?.trim(); if (!branchName) { return; diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionBranchActions.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionBranchActions.test.ts new file mode 100644 index 00000000000000..869069699812ed --- /dev/null +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionBranchActions.test.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IAgentHostConnectionsService, IAgentHostSessionIdentity } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { isIMenuItem, MenuRegistry } from '../../../../../../platform/actions/common/actions.js'; +import { IClipboardService } from '../../../../../../platform/clipboard/common/clipboardService.js'; +import { CommandsRegistry } from '../../../../../../platform/commands/common/commands.js'; +import { ServiceCollection } from '../../../../../../platform/instantiation/common/serviceCollection.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; +import { COPY_AGENT_HOST_CHAT_LINK_COMMAND_ID, COPY_AGENT_HOST_SESSION_LINK_COMMAND_ID } from '../../../../../common/sessionCommands.js'; +import { Menus } from '../../../../../browser/menus.js'; +import { IChat, ISession } from '../../../../../services/sessions/common/session.js'; +import '../../browser/agentHostSessionBranchActions.js'; + +suite('Agent Host session link actions', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('contributes chat link copying in a separate menu group', () => { + const item = MenuRegistry.getMenuItems(Menus.SessionChatItemContext) + .filter(isIMenuItem) + .find(item => item.command.id === COPY_AGENT_HOST_CHAT_LINK_COMMAND_ID); + + assert.deepStrictEqual(item && { + title: typeof item.command.title === 'string' ? item.command.title : item.command.title.value, + group: item.group, + order: item.order, + when: item.when?.serialize(), + }, { + title: 'Copy Link', + group: '2_copy', + order: 1, + when: 'sessionProviderId =~ /^(local-agent-host|agenthost-)/', + }); + }); + + test('copies browser links for sessions and chats', async () => { + const copied: string[] = []; + const clipboardService = new class extends mock() { + override async writeText(text: string): Promise { + copied.push(text); + } + }; + const connectionsService = new class extends mock() { + override resolveSessionResourceIdentity(): IAgentHostSessionIdentity { + return upcastPartial({ + backendSession: URI.parse('copilotcli:/session-1'), + }); + } + }; + const instantiationService = store.add(new TestInstantiationService(new ServiceCollection( + [IClipboardService, clipboardService], + [IAgentHostConnectionsService, connectionsService], + [IProductService, upcastPartial({ urlProtocol: 'vscode-insiders' })], + ))); + const session = upcastPartial({ + resource: URI.parse('agent-host-copilotcli:/session-1'), + }); + const chat = upcastPartial({ + resource: session.resource.with({ fragment: 'chat-2' }), + title: constObservable('Chat 2'), + }); + + await instantiationService.invokeFunction(CommandsRegistry.getCommand(COPY_AGENT_HOST_SESSION_LINK_COMMAND_ID)!.handler, session); + await instantiationService.invokeFunction(CommandsRegistry.getCommand(COPY_AGENT_HOST_CHAT_LINK_COMMAND_ID)!.handler, { session, chat }); + + assert.deepStrictEqual(copied, [ + 'vscode-insiders://agents/agent-host-session/copilotcli/session-1', + 'vscode-insiders://agents/agent-host-session/copilotcli/session-1/chat/chat-2', + ]); + }); +}); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index eea4e91f11b958..443a161475e83f 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -231,12 +231,10 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid this._omitHostFromWorkspaceLabel = config.omitHostFromWorkspaceLabel === true; this._workspaceTypeIcon = config.workspaceTypeIcon; this._defaultChangesetKind = config.defaultChangesetKind; - if (this._sessionSchemeAlias || this._defaultChangesetKind) { - this._register(agentHostConnectionsService.registerSessionResolutionPolicy(this._connectionAuthority, { - sessionSchemeAlias: this._sessionSchemeAlias, - defaultChangesetKind: this._defaultChangesetKind, - })); - } + this._register(agentHostConnectionsService.registerSessionResolutionPolicy(this._connectionAuthority, { + sessionSchemeAlias: this._sessionSchemeAlias, + defaultChangesetKind: this._defaultChangesetKind, + })); this._devContainerWorktreeScope = config.devContainerWorktreeScope; this.onDidReportConnectProgress = config.onDidReportConnectProgress; this.autoConnect = config.autoConnect; diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 2cc5e689089d7e..f834cc543b2f9b 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -3909,6 +3909,7 @@ export class SessionsList extends Disposable implements ISessionsList { [SessionChatItemCanRenameContext.key, capabilities.canRename], [SessionChatItemCanDeleteContext.key, capabilities.canDelete], [SessionChatItemIsUntitledContext.key, element.chat.status.get() === SessionStatus.Untitled], + [SessionProviderIdContext.key, element.session.providerId], ]); const menu = this.menuService.createMenu(Menus.SessionChatItemContext, contextKeyService); const actions = Separator.join(...menu.getActions({ arg: element, shouldForwardArgs: true }).map(([, groupActions]) => groupActions)); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsListContextMenu.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsListContextMenu.test.ts index 03e3eaff665372..3a056f6928f581 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsListContextMenu.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsListContextMenu.test.ts @@ -171,7 +171,6 @@ suite('Sessions list context menus', () => { }); test('chat rows expose capability-gated rename, side-open, and deletion', async () => { - assert.strictEqual(MenuRegistry.getMenuItems(Menus.SessionChatItemContext).length, 3); const createChat = (title: string, canRename: boolean, canDelete: boolean): IChat => upcastPartial({ resource: URI.parse(`test-chat:/${title}`), title: constObservable(title), @@ -223,7 +222,10 @@ suite('Sessions list context menus', () => { } }); }); - const menuItems = MenuRegistry.getMenuItems(Menus.SessionChatItemContext).filter(isIMenuItem); + const coreActionIds = new Set(['sessions.list.renameChat', 'sessions.list.openChatToSide', 'sessions.list.deleteChat']); + const menuItems = MenuRegistry.getMenuItems(Menus.SessionChatItemContext) + .filter(isIMenuItem) + .filter(item => coreActionIds.has(item.command.id)); assert.deepStrictEqual(menuItems.map(item => ({ id: item.command.id, title: typeof item.command.title === 'string' ? item.command.title : item.command.title.value,