Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
1 change: 1 addition & 0 deletions src/vs/base/common/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 20 additions & 1 deletion src/vs/code/electron-main/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -1050,6 +1051,15 @@ export class CodeApplication extends Disposable {
private async handleProtocolUrl(windowsMainService: IWindowsMainService, dialogMainService: IDialogMainService, urlService: IURLService, uri: URI, options?: IOpenURLOptions): Promise<boolean> {
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({
Expand Down Expand Up @@ -1497,6 +1507,15 @@ export class CodeApplication extends Disposable {

// Then check for windows from protocol links to open
if (initialProtocolUrls) {
const agentSessionProtocolUrl = initialProtocolUrls.urls.find(protocolUrl =>
parseExternalOpenSessionLinkUri(protocolUrl.uri, this.productService.urlProtocol));
if (agentSessionProtocolUrl) {
return windowsMainService.openAgentsWindow({
context: OpenContext.LINK,
cli: args,
initialStartup: true,
}, undefined, undefined, AgentsWindowOpenSource.Link);
}
Comment thread
sandy081 marked this conversation as resolved.
Outdated

// Openables can open as windows directly
if (initialProtocolUrls.openables.length > 0) {
Expand Down
44 changes: 44 additions & 0 deletions src/vs/platform/agentHost/common/openSessionLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,6 +26,8 @@ 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}/`;

export type AgentSessionLinkStatus = 'untitled' | 'inProgress' | 'needsInput' | 'completed' | 'error';

export function buildAgentSessionLinkPresentation(title: string, description: string | undefined, status: AgentSessionLinkStatus, kind: 'session' | 'chat' = 'session'): ILinkPresentation {
Expand Down Expand Up @@ -103,6 +106,47 @@ 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: `<product-protocol>://agents/agent-host-session/<provider>/<rawSessionId>`.
*/
export function buildExternalOpenSessionLinkUri(productUrlProtocol: string, backendSession: URI | string, chatId?: string, turnId?: string): string {
const sessionLink = URI.parse(buildOpenSessionLinkUri(backendSession, chatId, turnId));
return URI.from({
scheme: productUrlProtocol,
authority: AGENTS_AUTHORITY,
path: `${AGENT_HOST_SESSION_LINK_PATH_PREFIX}${sessionLink.authority}${sessionLink.path}`,
query: sessionLink.query,
fragment: sessionLink.fragment,
}).toString(true);
}
Comment thread
sandy081 marked this conversation as resolved.

/**
* 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 sessionLink = URI.from({
scheme: AGENT_HOST_SESSION_LINK_SCHEME,
authority: sessionPath.slice(0, providerEnd),
path: sessionPath.slice(providerEnd),
query: parsed.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.
Expand Down
23 changes: 22 additions & 1 deletion src/vs/platform/agentHost/test/common/openSessionLink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -41,6 +41,27 @@ 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('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'),
], [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');
Expand Down
2 changes: 2 additions & 0 deletions src/vs/platform/window/common/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export const enum AgentsWindowOpenSource {
ChatHandoff = 'chatHandoff',
Banner = 'banner',
CommandLine = 'commandLine',
Link = 'link',
Unknown = 'unknown',
}

Expand All @@ -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:
Expand Down
24 changes: 14 additions & 10 deletions src/vs/sessions/browser/parts/chatCompositeBar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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] : [],
);
}
});
}));
Expand Down
6 changes: 6 additions & 0 deletions src/vs/sessions/common/sessionCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

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

Expand Down Expand Up @@ -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;
},
Expand Down Expand Up @@ -154,7 +154,7 @@ export interface ISessionLinkState {
readonly chats: IObservable<readonly ISessionLinkChatState[]>;
}

function findSession(
export function findSessionForOpenSessionLink(
backendSession: URI,
sessionsManagementService: ISessionsManagementService,
connectionsService: IAgentHostConnectionsService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, copy a link to it, or, when supported, permanently delete it."));
Comment thread
sandy081 marked this conversation as resolved.
Outdated
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}.", `<keybinding:${FOCUS_PREVIOUS_CHAT_GROUP_COMMAND_ID}>`, `<keybinding:${FOCUS_NEXT_CHAT_GROUP_COMMAND_ID}>`, `<keybinding:${SPLIT_CHAT_GROUP_RIGHT_COMMAND_ID}>`, `<keybinding:${SPLIT_CHAT_GROUP_DOWN_COMMAND_ID}>`, `<keybinding:${MOVE_CHAT_TO_PREVIOUS_GROUP_COMMAND_ID}>`, `<keybinding:${MOVE_CHAT_TO_NEXT_GROUP_COMMAND_ID}>`));
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."));
Expand Down
Loading
Loading