From 883304403fec73a8f660503c9a77c1814b783e27 Mon Sep 17 00:00:00 2001 From: "vs-code-engineering[bot]" Date: Sun, 6 Sep 2026 21:02:04 +0000 Subject: [PATCH 1/2] [cherry-pick] Hide session chat pills in subagent chats --- .../chat/browser/sessionChatInputToolbar.ts | 42 ++ .../browser/sessionChatInputToolbar.test.ts | 93 ++- .../agentHost/agentHostSessionInputPills.ts | 566 ++++++++++++++ .../agentHostSessionInputPills.test.ts | 714 ++++++++++++++++++ 4 files changed, 1414 insertions(+), 1 deletion(-) create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts diff --git a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts index 17285abb69f71b..39abb01e6018a4 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts @@ -28,7 +28,11 @@ import { VIEW_SESSION_CHANGES_COMMAND_ID } from '../../changes/common/changes.js import { OPEN_ISSUE_ACTION_ID, OPEN_PULL_REQUEST_ACTION_ID } from '../../github/common/types.js'; import { getSessionChatPillMenu, SessionChatPillKind, SessionChatPillVisibility, type ISessionChatPillMenuEntry } from '../common/sessionChatPills.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +<<<<<<< HEAD import { IChat } from '../../../services/sessions/common/session.js'; +======= +import { ChatOriginKind, getGitHubPullRequestRefs, IChat, type IGitHubIssueRef } from '../../../services/sessions/common/session.js'; +>>>>>>> cbb81cdaae0 (Merge pull request #334510 from microsoft/copilot/hide-chat-pills-subagent-chats) import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; import { SessionBackgroundActivitiesControl, sessionSubagentsPillOptions } from './sessionBackgroundActivitiesControl.js'; import { SessionBrowsersControl, sessionBrowsersPillOptions } from './sessionBrowsersControl.js'; @@ -129,6 +133,13 @@ export class SessionChatInputToolbar extends Disposable { return this._findOwningSession(chat.resource, reader); }); + /** + * Whether the reflected chat is a subagent (worker) chat. Its pills describe + * the session the subagent was spawned from rather than the subagent's own + * work, so the row stays hidden there. + */ + private readonly _isSubagentChat: IObservable = derived(this, reader => this._chat.read(reader)?.origin?.kind === ChatOriginKind.Tool); + /** The current turn's diff stats. */ private readonly _diffStats: IObservable; /** Artifact sections shown in the artifact pill. */ @@ -181,6 +192,7 @@ export class SessionChatInputToolbar extends Disposable { const sessionCustomizations = this._register(instantiationService.createInstance(SessionCustomizations, this._chat, this._session)); this._customizationSections = sessionCustomizations.sections; +<<<<<<< HEAD const pillsEnabled = derived(reader => this._debugData.read(reader) !== undefined || turnStatusPillsEnabled.read(reader)); const model: IChatTurnPillsModel = { stats: this._diffStats, @@ -201,6 +213,14 @@ export class SessionChatInputToolbar extends Disposable { ...metadataPills.pills.read(reader), ...turn.filter(pill => pill.action.id !== CHAT_TURN_CHANGES_PILL_ID), ]; +======= + const pillsVisible = derived(this, reader => this._debugData.read(reader) !== undefined || !this._isSubagentChat.read(reader)); + this._backgroundActivities = this._register(instantiationService.createInstance(SessionBackgroundActivitiesControl, this._session, this._chat, pillsEnabled, constObservable(true))); + const gitHubInfo = derived(this, reader => { + const session = this._session.read(reader); + const workspace = session?.workspace.read(reader); + return workspace?.folders[0]?.gitRepository?.gitHubInfo.read(reader); +>>>>>>> cbb81cdaae0 (Merge pull request #334510 from microsoft/copilot/hide-chat-pills-subagent-chats) }); this._backgroundActivities = this._register(instantiationService.createInstance(SessionBackgroundActivitiesControl, this._session, this._chat, turnStatusPillsEnabled, derived(reader => visibility.isVisible(SessionChatPillKind.Subagents, reader)))); @@ -319,6 +339,7 @@ export class SessionChatInputToolbar extends Disposable { groups.push(menu.withData.map(toggleAction), menu.withoutData.map(toggleAction)); return Separator.join(...groups); }, +<<<<<<< HEAD }); })); @@ -350,6 +371,27 @@ export class SessionChatInputToolbar extends Disposable { this._onDidChangeVisibility.fire(visible); } this._scrollable.scanDomNode(); +======= + }, + pullRequests: { sections: pullRequestSections, icon: pullRequestPresentation.icon }, + issues: { sections: issueSections, icon: issueIcon }, + artifacts: { sections: this._artifactSections }, + references: { sections: this._referenceSections }, + customizations: { sections: this._customizationSections }, + browsers: { sections: this._browsers.sections }, + subagents: { sections: this._backgroundActivities.sections }, + }, SESSION_CHAT_PILL_KINDS)); + const actionRunner = this._register(new SessionActivatingActionRunner(() => this._session.get(), this._sessionsService)); + this._inputPills = this._register(instantiationService.createInstance(ChatInputPills, undefined, { + debugName: 'SessionChatInputToolbar.content', + compact, + enabled: pillsVisible, + sources: constObservable(sources.sources), + offeredKinds: SESSION_CHAT_PILL_KINDS, + context: this._session, + actionRunner, + focusFallback, +>>>>>>> cbb81cdaae0 (Merge pull request #334510 from microsoft/copilot/hide-chat-pills-subagent-chats) })); } diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts index f8743c0f92e48f..e9f000fb238179 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +<<<<<<< HEAD import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { CHAT_TURN_ARTIFACT_PILL_ID, CHAT_TURN_CHANGES_PILL_ID } from '../../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; import { VIEW_SESSION_CHANGES_COMMAND_ID } from '../../../changes/common/changes.js'; @@ -11,9 +12,33 @@ import { OPEN_ISSUE_ACTION_ID, OPEN_PULL_REQUEST_ACTION_ID } from '../../../gith import { SessionChatPillKind } from '../../common/sessionChatPills.js'; import { getSessionChatPillKindForAction, SESSION_BROWSERS_PILL_ID, SESSION_SUBAGENTS_PILL_ID } from '../../browser/sessionChatInputToolbar.js'; import { SESSION_CUSTOMIZATIONS_PILL_ID } from '../../browser/sessionCustomizations.js'; +======= +import { isManagedHoverTooltipHTMLElement } from '../../../../../base/browser/ui/hover/hover.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Event } from '../../../../../base/common/event.js'; +import { constObservable, derived } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import type { IChatPillEntry } from '../../../../../workbench/browser/chatPills.js'; +import { IBrowserViewWorkbenchService } from '../../../../../workbench/contrib/browserView/common/browserView.js'; +import { ISessionChatPillVisibilityService } from '../../../../../workbench/contrib/chat/common/sessionChatPills.js'; +import { workbenchInstantiationService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; +import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { ISessionChangesStatsCache } from '../../../../services/sessions/common/sessionChangesStatsCache.js'; +import { ChatOriginKind, SessionStatus, type IChat, type IGitHubIssueRef, type IGitHubPullRequestRef, type ISessionWorkspace } from '../../../../services/sessions/common/session.js'; +import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; +import { GitHubIssueState, GitHubPullRequestState, type IGitHubIssue, type IGitHubPullRequest } from '../../../github/common/types.js'; +import { buildSessionIssueSections, buildSessionPullRequestSections, computeSessionInputPillStats, SessionChatInputToolbar } from '../../browser/sessionChatInputToolbar.js'; +>>>>>>> cbb81cdaae0 (Merge pull request #334510 from microsoft/copilot/hide-chat-pills-subagent-chats) suite('SessionChatInputToolbar', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const store = ensureNoDisposablesAreLeakedInTestSuite(); test('maps turn-status and hosted pill actions onto togglable pill kinds', () => { assert.deepStrictEqual([ @@ -36,4 +61,70 @@ suite('SessionChatInputToolbar', () => { SessionChatPillKind.Subagents, ]); }); + + test('hides the pills in a subagent chat', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const chat = upcastPartial({ + resource: URI.parse('chat:main'), + title: constObservable('Main chat'), + status: constObservable(SessionStatus.InProgress), + }); + const subagentChat = upcastPartial({ + resource: URI.parse('chat:subagent'), + title: constObservable('Subagent'), + status: constObservable(SessionStatus.InProgress), + origin: { kind: ChatOriginKind.Tool, parentChat: chat.resource }, + }); + const forkedChat = upcastPartial({ + resource: URI.parse('chat:fork'), + title: constObservable('Fork'), + status: constObservable(SessionStatus.InProgress), + origin: { kind: ChatOriginKind.Fork, parentChat: chat.resource }, + }); + const session = upcastPartial({ + sessionId: 'provider:session', + resource: URI.parse('session:1'), + chats: constObservable([chat, subagentChat, forkedChat]), + workspace: constObservable(upcastPartial({ folders: [] })), + changesets: constObservable([]), + changes: constObservable([{ + modifiedUri: URI.file('/session-change.ts'), + insertions: 10, + deletions: 4, + }]), + }); + instantiationService.stub(IBrowserViewWorkbenchService, upcastPartial({ + onDidChangeBrowserViews: Event.None, + getKnownBrowserViews: () => new Map(), + })); + instantiationService.stub(ISessionChatPillVisibilityService, upcastPartial({ + readHiddenKinds: () => new Set(), + isVisible: () => true, + hide: () => { }, + toggle: () => { }, + })); + instantiationService.stub(ISessionChangesStatsCache, upcastPartial({ get: () => undefined })); + instantiationService.stub(ISessionsProvidersService, upcastPartial({ getProvider: () => undefined })); + instantiationService.stub(ISessionsService, upcastPartial({ + visibleSessions: constObservable([]), + activeSession: constObservable(undefined), + })); + const toolbar = store.add(instantiationService.createInstance(SessionChatInputToolbar, false, undefined)); + const read = () => ({ + pills: Array.from(toolbar.element.querySelectorAll('.chat-pill-label')).map(label => label.textContent), + visible: toolbar.visible, + }); + + toolbar.setSession(session, chat); + const main = read(); + toolbar.setSession(session, subagentChat); + const subagent = read(); + toolbar.setSession(session, forkedChat); + + assert.deepStrictEqual({ main, subagent, fork: read() }, { + main: { pills: ['1 File', 'Subagent'], visible: true }, + subagent: { pills: [], visible: false }, + fork: { pills: ['1 File'], visible: true }, + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts new file mode 100644 index 00000000000000..4d4691978947b4 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts @@ -0,0 +1,566 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getWindow } from '../../../../../../base/browser/dom.js'; +import { toAction } from '../../../../../../base/common/actions.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { constObservable, derived, derivedObservableWithCache, derivedOpts, observableFromEvent, observableSignal, observableSignalFromEvent } from '../../../../../../base/common/observable.js'; +import { basename, isEqual } from '../../../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { isDefined } from '../../../../../../base/common/types.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { localize } from '../../../../../../nls.js'; +import { IAgentHostConnectionsService, IAgentHostSessionResolution } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { resolveChangesetUriTemplate, selectDefaultChangeset, type DefaultChangesetKind } from '../../../../../../platform/agentHost/common/changesetUri.js'; +import { ISessionArtifact, isGitHubArtifactLink, readSessionArtifacts, SessionArtifactType } from '../../../../../../platform/agentHost/common/sessionArtifacts.js'; +import { observableFromSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; +import { Changeset, ChangesetState, ChangesetStatus, ChatOriginKind, DEFAULT_CHAT_ID, getSessionChatResource, getSessionRelatedPullRequestUrls, isSubagentChatUri, parseChatUri, readSessionGitHubState, SessionState, SessionSummaryMeta, StateComponents } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IClipboardService } from '../../../../../../platform/clipboard/common/clipboardService.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { CHAT_INPUT_PILLS_ROW_HEIGHT, getChatPillEntries, getChatPillResourceLocation, IChatPillEntry, IChatPillSection, type ChatPillsCompactMode } from '../../../../../browser/chatPills.js'; +import { chatChangesStatsEqual, EMPTY_CHAT_CHANGES_STATS, IChatChangesStats } from '../../../../../browser/chatChangesPill.js'; +import { BrowserEditorInput } from '../../../../browserView/common/browserEditorInput.js'; +import { browserViewUrlMatches, BrowserViewSharingState, IBrowserViewWorkbenchService } from '../../../../browserView/common/browserView.js'; +import { IEditorService } from '../../../../../services/editor/common/editorService.js'; +import { computePullRequestIcon, getHighestPriorityPullRequestIcon } from '../../../../../common/chatPullRequest.js'; +import { ISessionChatPillVisibilityService, SessionChatPillKind } from '../../../common/sessionChatPills.js'; +import { CHAT_SUBAGENT_RESOURCE_QUERY_PARAM } from '../../../common/constants.js'; +import { IEditSessionEntryDiff } from '../../../common/editing/chatEditingService.js'; +import { chatPersistentContentVisibleClass, type ChatWidget } from '../../widget/chatWidget.js'; +import { openChatTurnFile, previewKind } from '../../widget/chatTurnPills.js'; +import { openChatFileChanges } from '../../editorChatResponseFileChangesService.js'; +import { ChatInputPills, StandardChatInputPillSources } from '../../chatInputPills.js'; +import { agentHostChangesetFileToEntryDiff } from './agentHostResponseFileChanges.js'; + +const offeredPillKinds: readonly SessionChatPillKind[] = [ + SessionChatPillKind.Changes, + SessionChatPillKind.PullRequests, + SessionChatPillKind.Issues, + SessionChatPillKind.Artifacts, + SessionChatPillKind.References, + SessionChatPillKind.Browsers, +]; + +const artifactIcons: ReadonlyMap = new Map([ + [SessionArtifactType.PullRequest, Codicon.gitPullRequest], + [SessionArtifactType.Issue, Codicon.issues], + [SessionArtifactType.Commit, Codicon.gitCommit], + [SessionArtifactType.Website, Codicon.globe], + [SessionArtifactType.Resource, Codicon.link], +]); + +const artifactSectionOrder: readonly { readonly type: SessionArtifactType; readonly title: string }[] = [ + { type: SessionArtifactType.PullRequest, title: localize('agentHostSessionPills.artifacts.pullRequests', "Pull Requests") }, + { type: SessionArtifactType.Issue, title: localize('agentHostSessionPills.artifacts.issues', "Issues") }, + { type: SessionArtifactType.Commit, title: localize('agentHostSessionPills.artifacts.commits', "Commits") }, + { type: SessionArtifactType.Website, title: localize('agentHostSessionPills.artifacts.websites', "Websites") }, + { type: SessionArtifactType.File, title: localize('agentHostSessionPills.artifacts.files', "Files") }, + { type: SessionArtifactType.Resource, title: localize('agentHostSessionPills.artifacts.resources', "Resources") }, +]; + +export interface IAgentHostSessionPillMetadata { + readonly pullRequestUrls: readonly string[]; + readonly issueUrls: readonly string[]; + readonly artifacts: readonly ISessionArtifact[]; + readonly references: readonly ISessionArtifact[]; +} + +function linkKey(link: string): string { + return link.replace(/\/+$/, '').toLowerCase(); +} + +function dedupeLinks(...groups: readonly (readonly string[] | undefined)[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const group of groups) { + for (const link of group ?? []) { + const key = linkKey(link); + if (!seen.has(key)) { + seen.add(key); + result.push(link); + } + } + } + return result; +} + +function setsEqual(first: ReadonlySet, second: ReadonlySet): boolean { + return first === second || (first.size === second.size && [...first].every(value => second.has(value))); +} + +function isPromotedArtifact(artifact: ISessionArtifact, type: SessionArtifactType): artifact is ISessionArtifact & { readonly link: string } { + return artifact.isArtifact + && artifact.type === type + && artifact.isGitHub === true + && typeof artifact.link === 'string' + && isGitHubArtifactLink(artifact.link); +} + +/** Partitions Agent Host metadata into dedicated GitHub, artifact, and reference pills. */ +export function getAgentHostSessionPillMetadata(meta: SessionSummaryMeta | undefined): IAgentHostSessionPillMetadata { + const entries = readSessionArtifacts(meta); + const github = readSessionGitHubState(meta); + const artifactPullRequests = entries.filter(entry => isPromotedArtifact(entry, SessionArtifactType.PullRequest)).map(entry => entry.link); + const artifactIssues = entries.filter(entry => isPromotedArtifact(entry, SessionArtifactType.Issue)).map(entry => entry.link); + const pullRequestUrls = dedupeLinks(getSessionRelatedPullRequestUrls(github), artifactPullRequests); + const issueUrls = dedupeLinks(artifactIssues); + const promotedLinks = new Set([...pullRequestUrls, ...issueUrls].map(linkKey)); + const remaining = entries.filter(entry => !entry.link || !promotedLinks.has(linkKey(entry.link))); + return { + pullRequestUrls, + issueUrls, + artifacts: remaining.filter(entry => entry.isArtifact), + references: remaining.filter(entry => !entry.isArtifact), + }; +} + +/** Resolves the session-wide changeset represented by the workbench Changes pill. */ +export function resolveAgentHostSessionChangeset( + backendSession: URI, + changesets: readonly Changeset[] | undefined, + defaultKind?: DefaultChangesetKind, +): { readonly changeset: Changeset; readonly resource: URI } | undefined { + const staticChangesets = changesets?.filter(changeset => !changeset.uriTemplate.includes('{')) ?? []; + const changeset = selectDefaultChangeset(staticChangesets, defaultKind); + const resource = changeset ? parseUri(resolveChangesetUriTemplate(backendSession.toString(), changeset.uriTemplate)) : undefined; + return changeset && resource ? { changeset, resource } : undefined; +} + +/** Resolves the chat channel URI a workbench chat resource addresses. */ +export function getAgentHostSessionChatResource(sessionResource: URI, state: Pick | undefined): URI | undefined { + const explicitChatResource = new URLSearchParams(sessionResource.query).get(CHAT_SUBAGENT_RESOURCE_QUERY_PARAM); + if (explicitChatResource) { + return parseUri(explicitChatResource); + } + return state ? parseUri(getSessionChatResource(state, sessionResource.fragment || DEFAULT_CHAT_ID)?.toString()) : undefined; +} + +/** Returns the workbench chat resources whose browsers belong in the current chat's pill. */ +export function getAgentHostSessionBrowserOwnerIds(sessionResource: URI, state: Pick | undefined): ReadonlySet { + const ownerIds = new Set([sessionResource.toString()]); + if (!state) { + return ownerIds; + } + + const currentChatResource = getAgentHostSessionChatResource(sessionResource, state); + if (!currentChatResource) { + return ownerIds; + } + + for (const chat of state.chats) { + const parentChatResource = chat.origin?.kind === ChatOriginKind.Tool ? parseUri(chat.origin.chat) : undefined; + const parsedChat = parseChatUri(chat.resource); + if (!parentChatResource || !isEqual(parentChatResource, currentChatResource) || !parsedChat) { + continue; + } + + ownerIds.add(sessionResource.with({ fragment: parsedChat.chatId, query: null }).toString()); + const query = new URLSearchParams(sessionResource.query); + query.set(CHAT_SUBAGENT_RESOURCE_QUERY_PARAM, chat.resource); + ownerIds.add(sessionResource.with({ fragment: parsedChat.chatId, query: query.toString() }).toString()); + } + return ownerIds; +} + +function resolutionEquals(first: IAgentHostSessionResolution | undefined, second: IAgentHostSessionResolution | undefined): boolean { + return first === second || (!!first && !!second + && first.connection === second.connection + && first.connectionAuthority === second.connectionAuthority + && first.defaultChangesetKind === second.defaultChangesetKind + && isEqual(first.backendSession, second.backendSession)); +} + +function changesetTargetEquals( + first: { readonly changeset: Changeset; readonly resource: URI } | undefined, + second: { readonly changeset: Changeset; readonly resource: URI } | undefined, +): boolean { + return first === second || (!!first && !!second + && first.changeset.changeKind === second.changeset.changeKind + && first.changeset.label === second.changeset.label + && first.changeset.uriTemplate === second.changeset.uriTemplate + && isEqual(first.resource, second.resource)); +} + +function parseUri(value: string | undefined): URI | undefined { + if (!value) { + return undefined; + } + try { + return URI.parse(value, true); + } catch { + return undefined; + } +} + +function referenceLabel(link: string, kind: 'pullRequest' | 'issue'): string { + const resource = parseUri(link); + const number = resource ? githubReferenceNumber(resource, kind) : undefined; + if (kind === 'pullRequest') { + return number + ? localize('agentHostSessionPills.pullRequest.number', "Pull Request #{0}", number) + : localize('agentHostSessionPills.pullRequest', "Pull Request"); + } + return number + ? localize('agentHostSessionPills.issue.number', "Issue #{0}", number) + : localize('agentHostSessionPills.issue', "Issue"); +} + +function githubReferenceNumber(resource: URI, kind: 'pullRequest' | 'issue'): string | undefined { + const segment = kind === 'pullRequest' ? 'pull' : 'issues'; + return new RegExp(`/${segment}/(?\\d+)(?:/|$)`).exec(resource.path)?.groups?.number; +} + +function websiteKey(url: string): string | undefined { + const parsed = URL.parse(url); + if (!parsed) { + return undefined; + } + const path = parsed.pathname.length > 1 && parsed.pathname.endsWith('/') ? parsed.pathname.slice(0, -1) : parsed.pathname; + return `${parsed.protocol}//${parsed.host}${path}${parsed.search}${parsed.hash}`; +} + +/** Adds Agent Host session metadata pills to a workbench chat input. */ +export class AgentHostSessionInputPills extends Disposable { + + private readonly _browserChanged = observableSignal(this); + private readonly _browserListeners = this._register(new MutableDisposable()); + + constructor( + private readonly _widget: ChatWidget, + compact: ChatPillsCompactMode, + @IAgentHostConnectionsService connectionsService: IAgentHostConnectionsService, + @IBrowserViewWorkbenchService private readonly _browserViewService: IBrowserViewWorkbenchService, + @IClipboardService private readonly _clipboardService: IClipboardService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IEditorService private readonly _editorService: IEditorService, + @IInstantiationService instantiationService: IInstantiationService, + @IOpenerService private readonly _openerService: IOpenerService, + @ISessionChatPillVisibilityService visibility: ISessionChatPillVisibilityService, + ) { + super(); + + const sessionResource = observableFromEvent(this, this._widget.onDidChangeViewModel, () => this._widget.viewModel?.sessionResource); + const sessionResolutionChanged = observableSignalFromEvent(this, connectionsService.onDidChangeSessionResolution); + const resolution = derivedOpts({ owner: this, equalsFn: resolutionEquals }, reader => { + sessionResolutionChanged.read(reader); + const resource = sessionResource.read(reader); + return resource ? connectionsService.resolveSessionResource(resource) : undefined; + }); + const sessionStateSource = derived(this, reader => { + const current = resolution.read(reader); + if (!current) { + return constObservable(undefined); + } + const subscription = reader.store.add(current.connection.getSubscription(StateComponents.Session, current.backendSession, 'AgentHostSessionInputPills')); + return observableFromSubscription(this, subscription.object); + }); + const sessionState = derived(this, reader => sessionStateSource.read(reader).read(reader)); + // A subagent (worker) chat inherits the session-wide pills, where they read + // as the subagent's own work, so the row stays hidden there. + const subagentChat = derived(this, reader => { + const resource = sessionResource.read(reader); + const chatResource = resource ? getAgentHostSessionChatResource(resource, sessionState.read(reader)) : undefined; + return !!chatResource && isSubagentChatUri(chatResource); + }); + const pillsVisible = derived(this, reader => !subagentChat.read(reader)); + const changesetTarget = derivedOpts({ owner: this, equalsFn: changesetTargetEquals }, reader => { + const currentResolution = resolution.read(reader); + return currentResolution + ? resolveAgentHostSessionChangeset(currentResolution.backendSession, sessionState.read(reader)?.changesets, currentResolution.defaultChangesetKind) + : undefined; + }); + const changesetStateSource = derived(this, reader => { + const currentResolution = resolution.read(reader); + const resource = changesetTarget.read(reader)?.resource; + if (!currentResolution || !resource) { + return constObservable(undefined); + } + const subscription = reader.store.add(currentResolution.connection.getSubscription(StateComponents.Changeset, resource, 'AgentHostSessionInputPills')); + return observableFromSubscription(this, subscription.object); + }); + const changesetFiles = derivedObservableWithCache<{ readonly connectionAuthority: string; readonly resource: URI; readonly files: ChangesetState['files'] } | undefined>(this, (reader, lastValue) => { + const currentResolution = resolution.read(reader); + const target = changesetTarget.read(reader); + if (!currentResolution || !target) { + return undefined; + } + const state = changesetStateSource.read(reader).read(reader); + if (!state) { + return lastValue?.connectionAuthority === currentResolution.connectionAuthority && isEqual(lastValue.resource, target.resource) ? lastValue : undefined; + } + if (state.status !== ChangesetStatus.Ready && lastValue?.connectionAuthority === currentResolution.connectionAuthority && isEqual(lastValue.resource, target.resource)) { + return lastValue; + } + return { connectionAuthority: currentResolution.connectionAuthority, resource: target.resource, files: state.files }; + }); + const changes = derived(this, reader => { + const currentResolution = resolution.read(reader); + const files = changesetFiles.read(reader)?.files; + if (!currentResolution || !files) { + return []; + } + return files + .map(file => agentHostChangesetFileToEntryDiff(file, currentResolution.connectionAuthority)) + .filter(isDefined); + }); + const changeStats = derivedOpts({ owner: this, equalsFn: chatChangesStatsEqual }, reader => { + const diffs = changes.read(reader); + return diffs.length === 0 + ? EMPTY_CHAT_CHANGES_STATS + : { + files: diffs.length, + insertions: diffs.reduce((total, diff) => total + diff.added, 0), + deletions: diffs.reduce((total, diff) => total + diff.removed, 0), + }; + }); + const metadata = derived(this, reader => getAgentHostSessionPillMetadata(sessionState.read(reader)?._meta)); + const gitHubState = derived(this, reader => readSessionGitHubState(sessionState.read(reader)?._meta)); + + this._register(this._browserViewService.onDidChangeBrowserViews(() => this._refreshBrowserListeners())); + this._refreshBrowserListeners(); + const browserInputs = derived(this, reader => { + this._browserChanged.read(reader); + const resource = sessionResource.read(reader); + if (!resource || !resolution.read(reader)) { + return []; + } + const ownerIds = getAgentHostSessionBrowserOwnerIds(resource, sessionState.read(reader)); + return [...this._browserViewService.getKnownBrowserViews().values()] + .filter(input => input.model?.owner.type === 'agent' && ownerIds.has(input.model.owner.sessionId)); + }); + const browserUrls = derivedOpts>({ owner: this, equalsFn: setsEqual }, reader => { + return visibility.isVisible(SessionChatPillKind.Browsers, reader) + ? new Set(browserInputs.read(reader).map(input => input.url).filter(isDefined)) + : new Set(); + }); + + const pullRequestSections = derived(this, reader => this._buildReferenceSections(metadata.read(reader).pullRequestUrls, 'pullRequest', gitHubState.read(reader))); + const pullRequestIcon = derived(this, reader => { + const icons = getChatPillEntries(pullRequestSections.read(reader)).map(entry => entry.icon); + return getHighestPriorityPullRequestIcon(icons) ?? computePullRequestIcon('open'); + }); + const issueSections = derived(this, reader => this._buildReferenceSections(metadata.read(reader).issueUrls, 'issue')); + const artifactSections = derived(this, reader => { + const currentResolution = resolution.read(reader); + return currentResolution + ? this._buildArtifactSections(metadata.read(reader).artifacts, browserUrls.read(reader), currentResolution) + : []; + }); + const referenceSections = derived(this, reader => { + const currentResolution = resolution.read(reader); + return currentResolution + ? this._buildArtifactSections(metadata.read(reader).references, browserUrls.read(reader), currentResolution) + : []; + }); + const browserSections = derived(this, reader => { + const entries = browserInputs.read(reader).map(input => this._browserEntry(input, sessionResource.read(reader))); + return entries.length > 0 ? [{ title: localize('agentHostSessionPills.browsers.section', "Browsers"), entries }] : []; + }); + + const sources = this._register(instantiationService.createInstance(StandardChatInputPillSources, { + changes: { + stats: changeStats, + label: derived(this, reader => changesetTarget.read(reader)?.changeset.label ?? localize('agentHostSessionPills.changes', "Changes")), + open: () => this._openChanges(changesetTarget.get()?.changeset.label ?? localize('agentHostSessionPills.changesEditor', "Session Changes"), changes.get()), + }, + pullRequests: { sections: pullRequestSections, icon: pullRequestIcon }, + issues: { sections: issueSections }, + artifacts: { sections: artifactSections }, + references: { sections: referenceSections }, + browsers: { sections: browserSections }, + }, offeredPillKinds)); + const inputPills = this._register(instantiationService.createInstance(ChatInputPills, this._widget.inputPart.persistentContentContainerElement, { + debugName: 'AgentHostSessionInputPills.content', + compact, + targetWindow: getWindow(this._widget.inputPart.persistentContentContainerElement), + enabled: pillsVisible, + sources: constObservable(sources.sources), + offeredKinds: offeredPillKinds, + ariaLabel: localize('agentHostSessionPills.ariaLabel', "Session status"), + focusFallback: () => this._widget.focusInput(), + })); + inputPills.element.classList.add('agent-host-session-input-pills'); + + this._register(this._widget.inputPart.registerChatPetHorizontalPlatformProvider({ + onDidChange: inputPills.onDidChange, + getElements: () => inputPills.getPillElements(), + })); + const updateVisibility = (visible: boolean) => { + this._widget.inputPart.persistentContentContainerElement.classList.toggle(chatPersistentContentVisibleClass, visible); + this._widget.setPersistentContentHeight(visible ? CHAT_INPUT_PILLS_ROW_HEIGHT : undefined); + }; + this._register(inputPills.onDidChangeVisibility(updateVisibility)); + updateVisibility(inputPills.visible); + } + + private _buildReferenceSections(links: readonly string[], kind: 'pullRequest' | 'issue', gitHubState?: ReturnType): readonly IChatPillSection[] { + const entries = links.map(link => { + const resource = parseUri(link); + if (!resource) { + return undefined; + } + const number = githubReferenceNumber(resource, kind); + const label = referenceLabel(link, kind); + const pullRequestState = kind === 'pullRequest' + && gitHubState?.pullRequestState + && gitHubState.pullRequestStateUrl + && linkKey(gitHubState.pullRequestStateUrl) === linkKey(link) + ? gitHubState.pullRequestState + : 'open'; + return { + id: linkKey(link), + label, + ...(kind === 'pullRequest' && number ? { pillLabel: `#${number}` } : {}), + icon: kind === 'pullRequest' ? computePullRequestIcon(pullRequestState) : Codicon.issues, + toolbarActions: [toAction({ + id: `chatInputPills.copy.${kind}.${linkKey(link)}`, + label: kind === 'pullRequest' + ? localize('agentHostSessionPills.copyPullRequest', "Copy Pull Request URL") + : localize('agentHostSessionPills.copyIssue', "Copy Issue URL"), + class: ThemeIcon.asClassName(Codicon.copy), + run: () => this._clipboardService.writeText(resource.toString(true)), + })], + ...getChatPillResourceLocation(resource, label), + open: () => this._openExternal(resource), + } satisfies IChatPillEntry; + }).filter(isDefined); + const title = kind === 'pullRequest' + ? localize('agentHostSessionPills.pullRequests.section', "Pull Requests") + : localize('agentHostSessionPills.issues.section', "Issues"); + return entries.length > 0 ? [{ title, entries }] : []; + } + + private _buildArtifactSections(entries: readonly ISessionArtifact[], browserUrls: ReadonlySet, resolution: IAgentHostSessionResolution): readonly IChatPillSection[] { + const browserKeys = new Set([...browserUrls].map(websiteKey).filter(isDefined)); + const entriesByType = new Map(); + for (const artifact of entries) { + if (artifact.type === SessionArtifactType.Website && artifact.link) { + const key = websiteKey(artifact.link); + if (key && browserKeys.has(key)) { + continue; + } + } + const entry = this._artifactEntry(artifact, resolution); + if (entry) { + const typeEntries = entriesByType.get(artifact.type) ?? []; + typeEntries.push(entry); + entriesByType.set(artifact.type, typeEntries); + } + } + return artifactSectionOrder.flatMap(({ type, title }) => { + const sectionEntries = entriesByType.get(type); + return sectionEntries?.length ? [{ title, entries: sectionEntries }] : []; + }); + } + + private _artifactEntry(artifact: ISessionArtifact, resolution: IAgentHostSessionResolution): IChatPillEntry | undefined { + if (artifact.type === SessionArtifactType.File || artifact.type === SessionArtifactType.Resource) { + const artifactResource = parseUri(artifact.uri); + if (!artifactResource) { + return undefined; + } + const resource = artifact.type === SessionArtifactType.File + ? toAgentHostUri(artifactResource, resolution.connectionAuthority) + : artifactResource; + const label = artifact.type === SessionArtifactType.File ? basename(resource) : artifact.label; + return { + id: artifact.id, + label, + ...(artifact.type === SessionArtifactType.File ? { resource } : { icon: Codicon.link }), + ...getChatPillResourceLocation(resource, label), + open: () => this._openResource(resource), + }; + } + + const link = parseUri(artifact.link); + const icon = artifactIcons.get(artifact.type) ?? Codicon.archive; + if (link) { + const copyAction = artifact.type === SessionArtifactType.Commit && artifact.commitHash + ? [toAction({ + id: 'chat.agentHost.sessionPills.copyCommitHash', + label: localize('agentHostSessionPills.copyCommitHash', "Copy Commit Hash"), + class: ThemeIcon.asClassName(Codicon.copy), + run: () => this._clipboardService.writeText(artifact.commitHash!), + })] + : undefined; + return { + id: artifact.id, + label: artifact.label, + icon, + ...(copyAction ? { toolbarActions: copyAction } : {}), + ...getChatPillResourceLocation(link, artifact.label), + open: () => this._openExternal(link), + }; + } + if (artifact.type === SessionArtifactType.Commit && artifact.commitHash) { + return { + id: artifact.id, + label: artifact.label, + icon, + ariaLabel: localize('agentHostSessionPills.copyCommit', "Copy commit hash for {0}", artifact.label), + tooltip: artifact.commitHash, + open: () => { void this._clipboardService.writeText(artifact.commitHash!); }, + }; + } + return undefined; + } + + private _browserEntry(input: BrowserEditorInput, sessionResource: URI | undefined): IChatPillEntry { + const label = input.title?.trim() || localize('agentHostSessionPills.browser', "Browser"); + return { + id: input.id, + label, + icon: Codicon.globe, + open: () => { void this._openBrowser(input, sessionResource); }, + }; + } + + private async _openBrowser(input: BrowserEditorInput, sessionResource: URI | undefined): Promise { + const url = input.url; + const shared = url + ? [...this._browserViewService.getContextualBrowserViews({ activeSessionId: sessionResource?.toString() }).values()] + .filter(candidate => candidate.model?.sharingState === BrowserViewSharingState.Shared && browserViewUrlMatches(candidate.url, url)) + : []; + const target = input.model?.sharingState === BrowserViewSharingState.Shared || !url + ? input + : shared.find(candidate => candidate.url === url) ?? shared.at(0) ?? input; + const existing = this._editorService.findEditors(target.resource) + .find(identifier => identifier.editor instanceof BrowserEditorInput && identifier.editor.id === target.id); + const targetGroup = existing?.groupId ?? await this._browserViewService.getPreferredGroup(); + await this._editorService.openEditor(target, undefined, targetGroup); + } + + private _openChanges(label: string, diffs: readonly IEditSessionEntryDiff[]): void { + if (diffs.length > 0) { + openChatFileChanges(this._editorService, label, diffs); + } + } + + private _openExternal(resource: URI): void { + void this._openerService.open(resource, { openExternal: true, allowContributedOpeners: true, fromUserGesture: true }); + } + + private _openResource(resource: URI): void { + const kind = previewKind(resource); + if (kind) { + void openChatTurnFile({ uri: resource, kind, created: false }, this._openerService, this._configurationService); + return; + } + void this._openerService.open(resource, { fromUserGesture: true }); + } + + private _refreshBrowserListeners(): void { + const store = new DisposableStore(); + this._browserListeners.value = store; + for (const input of this._browserViewService.getKnownBrowserViews().values()) { + store.add(input.onDidChangeLabel(() => this._browserChanged.trigger(undefined))); + } + this._browserChanged.trigger(undefined); + } +} diff --git a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts new file mode 100644 index 00000000000000..c8361279657426 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts @@ -0,0 +1,714 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Disposable, toDisposable, type IReference } from '../../../../../../base/common/lifecycle.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 } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { ChangesetKind } from '../../../../../../platform/agentHost/common/changesetUri.js'; +import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; +import { ISessionArtifact, SessionArtifactType, withSessionArtifacts } from '../../../../../../platform/agentHost/common/sessionArtifacts.js'; +import { buildDefaultChatUri, buildSubagentChatUri, Changeset, ChangesetState, ChangesetStatus, ChatOriginKind, ComponentToState, SessionState, StateComponents, withSessionGitHubState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IClipboardService } from '../../../../../../platform/clipboard/common/clipboardService.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { BrowserEditorInput } from '../../../../browserView/common/browserEditorInput.js'; +import { IBrowserViewModel, IBrowserViewWorkbenchService } from '../../../../browserView/common/browserView.js'; +import { IEditorService } from '../../../../../services/editor/common/editorService.js'; +import { CHAT_SUBAGENT_RESOURCE_QUERY_PARAM } from '../../../common/constants.js'; +import { type IChatWidgetViewModelChangeEvent } from '../../../browser/chat.js'; +import { AgentHostSessionInputPills, getAgentHostSessionBrowserOwnerIds, getAgentHostSessionPillMetadata, resolveAgentHostSessionChangeset } from '../../../browser/agentSessions/agentHost/agentHostSessionInputPills.js'; +import { ISessionChatPillVisibilityService, SessionChatPillKind } from '../../../common/sessionChatPills.js'; +import { chatPersistentContentVisibleClass, ChatWidget } from '../../../browser/widget/chatWidget.js'; +import { ChatInputPart } from '../../../browser/widget/input/chatInputPart.js'; +import { ChatViewModel } from '../../../common/model/chatViewModel.js'; + +class StaticAgentConnection extends mock() { + readonly requested: Array<{ kind: StateComponents; resource: URI }> = []; + private readonly emitters = new Map>(); + + constructor(private readonly values: ReadonlyMap) { + super(); + } + + override getSubscription(kind: T, resource: URI): IReference> { + this.requested.push({ kind, resource }); + let emitter = this.emitters.get(kind); + if (!emitter) { + emitter = new Emitter(); + this.emitters.set(kind, emitter); + } + const values = this.values; + return { + object: { + get value() { return values.get(kind) as ComponentToState[T]; }, + get verifiedValue() { return values.get(kind) as ComponentToState[T]; }, + onDidChange: emitter.event as Event, + onWillApplyAction: Event.None, + onDidApplyAction: Event.None, + }, + dispose: () => { }, + }; + } + + setState(kind: StateComponents, value: SessionState | ChangesetState): void { + (this.values as Map).set(kind, value); + this.emitters.get(kind)?.fire(value); + } +} + +suite('AgentHostSessionInputPills', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('partitions GitHub links, artifacts, and references without duplication', () => { + const entries: readonly ISessionArtifact[] = [ + { id: 'created-pr', type: SessionArtifactType.PullRequest, label: 'Created PR', link: 'https://github.com/microsoft/vscode/pull/2', isGitHub: true, isArtifact: true }, + { id: 'duplicate-pr', type: SessionArtifactType.PullRequest, label: 'Existing PR', link: 'https://github.com/microsoft/vscode/pull/1/', isGitHub: true, isArtifact: false }, + { id: 'created-issue', type: SessionArtifactType.Issue, label: 'Created Issue', link: 'https://github.com/microsoft/vscode/issues/3', isGitHub: true, isArtifact: true }, + { id: 'issue-reference', type: SessionArtifactType.Issue, label: 'Related Issue', link: 'https://github.com/microsoft/vscode/issues/4', isGitHub: true, isArtifact: false }, + { id: 'website', type: SessionArtifactType.Website, label: 'Preview', link: 'https://example.com', isArtifact: true }, + { id: 'resource', type: SessionArtifactType.Resource, label: 'Docs', uri: 'https://example.com/docs', isArtifact: false }, + ]; + const meta = withSessionGitHubState( + withSessionArtifacts(undefined, entries), + { + pullRequestUrls: ['https://github.com/microsoft/vscode/pull/1'], + }, + ); + + const metadata = getAgentHostSessionPillMetadata(meta); + + assert.deepStrictEqual({ + pullRequestUrls: metadata.pullRequestUrls, + issueUrls: metadata.issueUrls, + artifactIds: metadata.artifacts.map(artifact => artifact.id), + referenceIds: metadata.references.map(reference => reference.id), + }, { + pullRequestUrls: [ + 'https://github.com/microsoft/vscode/pull/1', + 'https://github.com/microsoft/vscode/pull/2', + ], + issueUrls: ['https://github.com/microsoft/vscode/issues/3'], + artifactIds: ['website'], + referenceIds: ['issue-reference', 'resource'], + }); + }); + + test('resolves the configured session changeset and ignores templated entries', () => { + const backendSession = URI.parse('ahp-session:/session'); + const changesets: readonly Changeset[] = [ + { label: 'Last Turn', uriTemplate: 'changeset/turn/{turnId}', changeKind: ChangesetKind.Turn }, + { label: 'Session Changes', uriTemplate: 'changeset/session', changeKind: ChangesetKind.Session }, + { label: 'Branch Changes', uriTemplate: 'changeset/branch', changeKind: ChangesetKind.Branch }, + ]; + + assert.deepStrictEqual({ + preferred: resolveAgentHostSessionChangeset(backendSession, changesets, ChangesetKind.Session), + fallback: resolveAgentHostSessionChangeset(backendSession, changesets.slice(0, 2), ChangesetKind.Branch), + turnOnly: resolveAgentHostSessionChangeset(backendSession, changesets.slice(0, 1), ChangesetKind.Session), + }, { + preferred: { + changeset: changesets[1], + resource: URI.parse('ahp-session:/session/changeset/session'), + }, + fallback: { + changeset: changesets[1], + resource: URI.parse('ahp-session:/session/changeset/session'), + }, + turnOnly: undefined, + }); + }); + + test('includes browsers owned by direct tool-origin child chats', () => { + const sessionResource = URI.parse('vscode-chat-session://agent-host/session'); + const backendSession = URI.parse('ahp-session://host/session'); + const parentChat = buildDefaultChatUri(backendSession); + const childChat = buildSubagentChatUri(backendSession, 'tool-1'); + const unrelatedChildChat = buildSubagentChatUri(backendSession, 'tool-2'); + const childChatId = 'subagent/tool-1'; + const stateWithoutChild = { + defaultChat: parentChat, + chats: [], + } as unknown as SessionState; + const stateWithChild = { + defaultChat: parentChat, + chats: [{ + resource: childChat, + origin: { kind: ChatOriginKind.Tool, chat: parentChat, toolCallId: 'tool-1' }, + }, { + resource: unrelatedChildChat, + origin: { kind: ChatOriginKind.Tool, chat: buildDefaultChatUri(URI.parse('ahp-session://host/other')), toolCallId: 'tool-2' }, + }], + } as unknown as SessionState; + const explicitQuery = new URLSearchParams(); + explicitQuery.set(CHAT_SUBAGENT_RESOURCE_QUERY_PARAM, childChat); + const canonicalChildResource = sessionResource.with({ fragment: childChatId, query: null }); + const explicitChildResource = sessionResource.with({ fragment: childChatId, query: explicitQuery.toString() }); + + const before = getAgentHostSessionBrowserOwnerIds(sessionResource, stateWithoutChild); + const after = getAgentHostSessionBrowserOwnerIds(sessionResource, stateWithChild); + + assert.deepStrictEqual({ + before: [...before], + after: [...after], + hasCanonicalChild: after.has(canonicalChildResource.toString()), + hasExplicitChild: after.has(explicitChildResource.toString()), + hasUnrelatedChild: after.has(sessionResource.with({ fragment: 'subagent/tool-2', query: null }).toString()), + }, { + before: [sessionResource.toString()], + after: [ + sessionResource.toString(), + canonicalChildResource.toString(), + explicitChildResource.toString(), + ], + hasCanonicalChild: true, + hasExplicitChild: true, + hasUnrelatedChild: false, + }); + }); + + test('does not render pills for a Local chat input', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const sessionResource = URI.parse('vscode-chat-session://local/session'); + const persistentContent = document.createElement('div'); + document.body.appendChild(persistentContent); + store.add(toDisposable(() => persistentContent.remove())); + let persistentContentHeight: number | undefined; + const widget = upcastPartial({ + inputPart: upcastPartial({ + persistentContentContainerElement: persistentContent, + registerChatPetHorizontalPlatformProvider: () => Disposable.None, + }), + onDidChangeViewModel: Event.None, + viewModel: upcastPartial({ sessionResource }), + setPersistentContentHeight: height => persistentContentHeight = height, + }); + const connectionsService = upcastPartial({ + onDidChangeConnections: Event.None, + onDidChangeSessionResolution: Event.None, + connections: [], + resolveSessionResource: () => undefined, + }); + const browserViewService = upcastPartial({ + onDidChangeBrowserViews: Event.None, + getKnownBrowserViews: () => new Map(), + }); + const visibility = upcastPartial({ + readHiddenKinds: () => new Set(), + isVisible: () => true, + hide: () => { }, + toggle: () => { }, + }); + instantiationService.stub(ISessionChatPillVisibilityService, visibility); + const [clipboardService, configurationService, editorService, openerService] = instantiationService.invokeFunction(accessor => [ + accessor.get(IClipboardService), + accessor.get(IConfigurationService), + accessor.get(IEditorService), + accessor.get(IOpenerService), + ] as const); + + store.add(new AgentHostSessionInputPills( + widget, + false, + connectionsService, + browserViewService, + clipboardService, + configurationService, + editorService, + instantiationService, + openerService, + visibility, + )); + const row = persistentContent.querySelector('.agent-host-session-input-pills'); + + assert.deepStrictEqual({ + hidden: row?.classList.contains('hidden'), + pillCount: row?.querySelectorAll('.chat-pill-item').length, + persistentContentVisible: persistentContent.classList.contains(chatPersistentContentVisibleClass), + persistentContentHeight, + }, { + hidden: true, + pillCount: 0, + persistentContentVisible: false, + persistentContentHeight: undefined, + }); + }); + + test('marks floating persistent content visible when Agent Host pills have data', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const sessionResource = URI.parse('agent-host-copilot:/session'); + const backendSession = URI.parse('copilot:/session'); + const connection = new StaticAgentConnection(new Map([ + [StateComponents.Session, { + defaultChat: buildDefaultChatUri(backendSession), + chats: [], + changesets: [{ label: 'Branch Changes', uriTemplate: 'changeset/branch', changeKind: ChangesetKind.Branch }], + } as unknown as SessionState], + [StateComponents.Changeset, { + status: ChangesetStatus.Ready, + files: [{ + id: 'change', + edit: { + after: { uri: URI.file('/changed.ts').toString(), content: { uri: 'git-blob://after' } }, + diff: { added: 3, removed: 1 }, + }, + }], + } as unknown as ChangesetState], + ])); + const otherConnection = new StaticAgentConnection(new Map([ + [StateComponents.Session, { + defaultChat: buildDefaultChatUri(backendSession), + chats: [], + changesets: [{ label: 'Branch Changes', uriTemplate: 'changeset/branch', changeKind: ChangesetKind.Branch }], + } as unknown as SessionState], + [StateComponents.Changeset, { + status: ChangesetStatus.Computing, + files: [], + } as unknown as ChangesetState], + ])); + const persistentContent = document.createElement('div'); + document.body.appendChild(persistentContent); + store.add(toDisposable(() => persistentContent.remove())); + let persistentContentHeight: number | undefined; + const widget = upcastPartial({ + inputPart: upcastPartial({ + persistentContentContainerElement: persistentContent, + registerChatPetHorizontalPlatformProvider: () => Disposable.None, + }), + onDidChangeViewModel: Event.None, + viewModel: upcastPartial({ sessionResource }), + setPersistentContentHeight: height => persistentContentHeight = height, + }); + const resolutionChanged = new Emitter(); + let currentConnection = connection; + let connectionAuthority = 'local'; + const connectionsService = upcastPartial({ + onDidChangeConnections: Event.None, + onDidChangeSessionResolution: resolutionChanged.event, + connections: [], + resolveSessionResource: () => ({ + connection: currentConnection, + connectionAuthority, + backendSession, + defaultChangesetKind: ChangesetKind.Branch, + }), + }); + const browserViewService = upcastPartial({ + onDidChangeBrowserViews: Event.None, + getKnownBrowserViews: () => new Map(), + }); + const visibility = upcastPartial({ + readHiddenKinds: () => new Set(), + isVisible: () => true, + hide: () => { }, + toggle: () => { }, + }); + instantiationService.stub(ISessionChatPillVisibilityService, visibility); + const [clipboardService, configurationService, editorService, openerService] = instantiationService.invokeFunction(accessor => [ + accessor.get(IClipboardService), + accessor.get(IConfigurationService), + accessor.get(IEditorService), + accessor.get(IOpenerService), + ] as const); + + store.add(new AgentHostSessionInputPills( + widget, + false, + connectionsService, + browserViewService, + clipboardService, + configurationService, + editorService, + instantiationService, + openerService, + visibility, + )); + const row = persistentContent.querySelector('.agent-host-session-input-pills'); + const button = row?.querySelector('.chat-pill-button'); + connection.setState(StateComponents.Changeset, { + status: ChangesetStatus.Computing, + files: [], + } as ChangesetState); + const recomputing = { + hidden: row?.classList.contains('hidden'), + buttonPreserved: row?.querySelector('.chat-pill-button') === button, + persistentContentVisible: persistentContent.classList.contains(chatPersistentContentVisibleClass), + persistentContentHeight, + }; + connection.setState(StateComponents.Changeset, { + status: ChangesetStatus.Ready, + files: [], + } as ChangesetState); + const readyEmpty = { + hidden: row?.classList.contains('hidden'), + persistentContentVisible: persistentContent.classList.contains(chatPersistentContentVisibleClass), + persistentContentHeight, + }; + connection.setState(StateComponents.Changeset, { + status: ChangesetStatus.Ready, + files: [{ + id: 'change', + edit: { + after: { uri: URI.file('/changed.ts').toString(), content: { uri: 'git-blob://after' } }, + diff: { added: 3, removed: 1 }, + }, + }], + } as ChangesetState); + connection.setState(StateComponents.Changeset, { + status: ChangesetStatus.Computing, + files: [], + } as ChangesetState); + currentConnection = otherConnection; + connectionAuthority = 'remote'; + resolutionChanged.fire(); + + assert.deepStrictEqual({ + recomputing, + readyEmpty, + otherConnection: { + hidden: row?.classList.contains('hidden'), + persistentContentVisible: persistentContent.classList.contains(chatPersistentContentVisibleClass), + persistentContentHeight, + }, + subscriptions: [...new Map(connection.requested.map(request => { + const value = { kind: request.kind, resource: request.resource.toString() }; + return [`${value.kind}:${value.resource}`, value]; + })).values()], + }, { + recomputing: { + hidden: false, + buttonPreserved: true, + persistentContentVisible: true, + persistentContentHeight: 28, + }, + readyEmpty: { + hidden: true, + persistentContentVisible: false, + persistentContentHeight: undefined, + }, + otherConnection: { + hidden: true, + persistentContentVisible: false, + persistentContentHeight: undefined, + }, + subscriptions: [{ + kind: StateComponents.Session, + resource: 'copilot:/session', + }, { + kind: StateComponents.Changeset, + resource: 'copilot:/session/changeset/branch', + }], + }); + }); + + test('matches the Agents Window pull request summary presentation', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const sessionResource = URI.parse('agent-host-copilot:/session'); + const backendSession = URI.parse('copilot:/session'); + const connection = new StaticAgentConnection(new Map([ + [StateComponents.Session, { + defaultChat: buildDefaultChatUri(backendSession), + chats: [], + _meta: withSessionGitHubState(undefined, { + pullRequestUrls: [ + 'https://github.com/microsoft/vscode/pull/1', + 'https://github.com/microsoft/vscode/pull/2', + 'https://github.com/microsoft/vscode/pull/3', + ], + // Only pull request #1 is merged; the other entries must retain their open state. + pullRequestState: 'merged', + pullRequestStateUrl: 'https://github.com/microsoft/vscode/pull/1', + }), + } as unknown as SessionState], + ])); + const persistentContent = document.createElement('div'); + document.body.appendChild(persistentContent); + store.add(toDisposable(() => persistentContent.remove())); + const widget = upcastPartial({ + inputPart: upcastPartial({ + persistentContentContainerElement: persistentContent, + registerChatPetHorizontalPlatformProvider: () => Disposable.None, + }), + onDidChangeViewModel: Event.None, + viewModel: upcastPartial({ sessionResource }), + setPersistentContentHeight: () => { }, + }); + const connectionsService = upcastPartial({ + onDidChangeConnections: Event.None, + onDidChangeSessionResolution: Event.None, + connections: [{ authority: 'local', address: undefined, name: 'Local', isAmbient: true, connection }], + resolveSessionResource: () => ({ connection, connectionAuthority: 'local', backendSession }), + }); + const browserViewService = upcastPartial({ + onDidChangeBrowserViews: Event.None, + getKnownBrowserViews: () => new Map(), + }); + const visibility = upcastPartial({ + readHiddenKinds: () => new Set(), + isVisible: () => true, + hide: () => { }, + toggle: () => { }, + }); + instantiationService.stub(ISessionChatPillVisibilityService, visibility); + const [clipboardService, configurationService, editorService, openerService] = instantiationService.invokeFunction(accessor => [ + accessor.get(IClipboardService), + accessor.get(IConfigurationService), + accessor.get(IEditorService), + accessor.get(IOpenerService), + ] as const); + + store.add(new AgentHostSessionInputPills( + widget, + false, + connectionsService, + browserViewService, + clipboardService, + configurationService, + editorService, + instantiationService, + openerService, + visibility, + )); + const button = persistentContent.querySelector('.chat-dropdown-pill-button'); + const icon = button?.querySelector('.chat-pill-icon'); + const multiple = { + button, + label: button?.querySelector('.chat-pill-label')?.textContent, + iconClass: icon?.classList.contains('codicon-git-pull-request'), + iconColor: icon?.style.color, + hasChevron: button?.querySelector('.chat-pill-chevron') !== null, + }; + connection.setState(StateComponents.Session, { + defaultChat: buildDefaultChatUri(backendSession), + chats: [], + _meta: withSessionGitHubState(undefined, { + pullRequestUrls: ['https://github.com/microsoft/vscode/pull/1'], + pullRequestState: 'merged', + pullRequestStateUrl: 'https://github.com/microsoft/vscode/pull/1', + }), + } as unknown as SessionState); + const singleButton = persistentContent.querySelector('.chat-dropdown-pill-button'); + const singleIcon = singleButton?.querySelector('.chat-pill-icon'); + + assert.deepStrictEqual({ + multiple, + single: { + buttonPreserved: singleButton === multiple.button, + label: singleButton?.querySelector('.chat-pill-label')?.textContent, + iconClass: singleIcon?.classList.contains('codicon-git-pull-request-done'), + iconColor: singleIcon?.style.color, + hasChevron: singleButton?.querySelector('.chat-pill-chevron') !== null, + }, + }, { + multiple: { + button, + label: '3 Pull Requests', + iconClass: true, + iconColor: 'var(--vscode-charts-green)', + hasChevron: true, + }, + single: { + buttonPreserved: true, + label: '#1', + iconClass: true, + iconColor: 'var(--vscode-charts-purple)', + hasChevron: false, + }, + }); + }); + + test('keeps a matching website artifact visible while Browsers is hidden', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const sessionResource = URI.parse('agent-host-copilot:/session'); + const backendSession = URI.parse('copilot:/session'); + const website = URI.parse('https://example.com/preview'); + const connection = new StaticAgentConnection(new Map([ + [StateComponents.Session, { + defaultChat: buildDefaultChatUri(backendSession), + chats: [], + _meta: withSessionArtifacts(undefined, [{ + id: 'preview', + type: SessionArtifactType.Website, + label: 'Preview', + link: website.toString(), + isArtifact: true, + }]), + } as unknown as SessionState], + ])); + const browserModel = upcastPartial({ + owner: { type: 'agent', sessionId: sessionResource.toString() }, + }); + const browser = new class extends mock() { + override get id(): string { return 'preview-browser'; } + override get model(): IBrowserViewModel { return browserModel; } + override get url(): string { return website.toString(); } + override get title(): string { return 'Preview'; } + override readonly onDidChangeLabel = Event.None; + }(); + const persistentContent = document.createElement('div'); + document.body.appendChild(persistentContent); + store.add(toDisposable(() => persistentContent.remove())); + const widget = upcastPartial({ + inputPart: upcastPartial({ + persistentContentContainerElement: persistentContent, + registerChatPetHorizontalPlatformProvider: () => Disposable.None, + }), + onDidChangeViewModel: Event.None, + viewModel: upcastPartial({ sessionResource }), + setPersistentContentHeight: () => { }, + }); + const connectionsService = upcastPartial({ + onDidChangeConnections: Event.None, + onDidChangeSessionResolution: Event.None, + connections: [], + resolveSessionResource: () => ({ connection, connectionAuthority: 'local', backendSession }), + }); + const browserViewService = upcastPartial({ + onDidChangeBrowserViews: Event.None, + getKnownBrowserViews: () => new Map([[browser.id, browser]]), + }); + const visibility = upcastPartial({ + readHiddenKinds: () => new Set([SessionChatPillKind.Browsers]), + isVisible: kind => kind !== SessionChatPillKind.Browsers, + hide: () => { }, + toggle: () => { }, + }); + instantiationService.stub(ISessionChatPillVisibilityService, visibility); + const [clipboardService, configurationService, editorService, openerService] = instantiationService.invokeFunction(accessor => [ + accessor.get(IClipboardService), + accessor.get(IConfigurationService), + accessor.get(IEditorService), + accessor.get(IOpenerService), + ] as const); + + store.add(new AgentHostSessionInputPills( + widget, + false, + connectionsService, + browserViewService, + clipboardService, + configurationService, + editorService, + instantiationService, + openerService, + visibility, + )); + + assert.deepStrictEqual({ + pills: Array.from(persistentContent.querySelectorAll('.chat-pill-label')).map(label => label.textContent), + empty: persistentContent.querySelector('.agent-host-session-input-pills')?.classList.contains('empty'), + }, { + pills: ['1 Artifact'], + empty: false, + }); + }); + + test('hides the session pills in a subagent chat', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const sessionResource = URI.parse('agent-host-copilot:/session'); + const backendSession = URI.parse('copilot:/session'); + const defaultChat = buildDefaultChatUri(backendSession); + const subagentChat = buildSubagentChatUri(backendSession, 'tool-1'); + const connection = new StaticAgentConnection(new Map([ + [StateComponents.Session, { + defaultChat, + chats: [{ + resource: subagentChat, + origin: { kind: ChatOriginKind.Tool, chat: defaultChat, toolCallId: 'tool-1' }, + }], + _meta: withSessionArtifacts(undefined, [{ + id: 'preview', + type: SessionArtifactType.Website, + label: 'Preview', + link: 'https://example.com/preview', + isArtifact: true, + }]), + } as unknown as SessionState], + ])); + const connectionsService = upcastPartial({ + onDidChangeConnections: Event.None, + onDidChangeSessionResolution: Event.None, + connections: [], + resolveSessionResource: () => ({ connection, connectionAuthority: 'local', backendSession }), + }); + const browserViewService = upcastPartial({ + onDidChangeBrowserViews: Event.None, + getKnownBrowserViews: () => new Map(), + }); + const visibility = upcastPartial({ + readHiddenKinds: () => new Set(), + isVisible: () => true, + hide: () => { }, + toggle: () => { }, + }); + instantiationService.stub(ISessionChatPillVisibilityService, visibility); + const [clipboardService, configurationService, editorService, openerService] = instantiationService.invokeFunction(accessor => [ + accessor.get(IClipboardService), + accessor.get(IConfigurationService), + accessor.get(IEditorService), + accessor.get(IOpenerService), + ] as const); + const persistentContent = document.createElement('div'); + document.body.appendChild(persistentContent); + store.add(toDisposable(() => persistentContent.remove())); + let persistentContentHeight: number | undefined; + // The chat editor keeps one pills instance while its widget navigates + // between the session and one of its subagent chats. + let viewModel = upcastPartial({ sessionResource }); + const viewModelChanged = store.add(new Emitter()); + const widget = upcastPartial({ + inputPart: upcastPartial({ + persistentContentContainerElement: persistentContent, + registerChatPetHorizontalPlatformProvider: () => Disposable.None, + }), + onDidChangeViewModel: viewModelChanged.event, + get viewModel() { return viewModel; }, + setPersistentContentHeight: height => persistentContentHeight = height, + }); + store.add(new AgentHostSessionInputPills( + widget, + false, + connectionsService, + browserViewService, + clipboardService, + configurationService, + editorService, + instantiationService, + openerService, + visibility, + )); + const showChat = (resource: URI) => { + const previousSessionResource = viewModel.sessionResource; + viewModel = upcastPartial({ sessionResource: resource }); + viewModelChanged.fire({ previousSessionResource, currentSessionResource: resource }); + return { + pills: Array.from(persistentContent.querySelectorAll('.chat-pill-label')).map(label => label.textContent), + hidden: persistentContent.querySelector('.agent-host-session-input-pills')?.classList.contains('hidden'), + persistentContentHeight, + }; + }; + const explicitQuery = new URLSearchParams(); + explicitQuery.set(CHAT_SUBAGENT_RESOURCE_QUERY_PARAM, subagentChat); + + assert.deepStrictEqual({ + session: showChat(sessionResource), + // The subagent editor addresses its chat by query parameter, and by + // fragment alone once the session state resolves the chat id. + explicitSubagent: showChat(sessionResource.with({ fragment: 'subagent/tool-1', query: explicitQuery.toString() })), + canonicalSubagent: showChat(sessionResource.with({ fragment: 'subagent/tool-1' })), + backToSession: showChat(sessionResource), + }, { + session: { pills: ['1 Artifact'], hidden: false, persistentContentHeight: 28 }, + explicitSubagent: { pills: [], hidden: true, persistentContentHeight: undefined }, + canonicalSubagent: { pills: [], hidden: true, persistentContentHeight: undefined }, + backToSession: { pills: ['1 Artifact'], hidden: false, persistentContentHeight: 28 }, + }); + }); +}); From 84ecaa172cbedb70464558aa43bf5e95307c8182 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 06:14:44 +0000 Subject: [PATCH 2/2] Resolve session chat pill cherry-pick conflicts Co-authored-by: dmitrivMS <9581278+dmitrivMS@users.noreply.github.com> --- .../chat/browser/sessionChatInputToolbar.ts | 45 +- .../browser/sessionChatInputToolbar.test.ts | 99 +-- .../agentHost/agentHostSessionInputPills.ts | 566 -------------- .../agentHostSessionInputPills.test.ts | 714 ------------------ 4 files changed, 56 insertions(+), 1368 deletions(-) delete mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts delete mode 100644 src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts diff --git a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts index 39abb01e6018a4..d6a99c549c2ee2 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts @@ -28,11 +28,7 @@ import { VIEW_SESSION_CHANGES_COMMAND_ID } from '../../changes/common/changes.js import { OPEN_ISSUE_ACTION_ID, OPEN_PULL_REQUEST_ACTION_ID } from '../../github/common/types.js'; import { getSessionChatPillMenu, SessionChatPillKind, SessionChatPillVisibility, type ISessionChatPillMenuEntry } from '../common/sessionChatPills.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -<<<<<<< HEAD -import { IChat } from '../../../services/sessions/common/session.js'; -======= -import { ChatOriginKind, getGitHubPullRequestRefs, IChat, type IGitHubIssueRef } from '../../../services/sessions/common/session.js'; ->>>>>>> cbb81cdaae0 (Merge pull request #334510 from microsoft/copilot/hide-chat-pills-subagent-chats) +import { ChatOriginKind, IChat } from '../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; import { SessionBackgroundActivitiesControl, sessionSubagentsPillOptions } from './sessionBackgroundActivitiesControl.js'; import { SessionBrowsersControl, sessionBrowsersPillOptions } from './sessionBrowsersControl.js'; @@ -132,12 +128,6 @@ export class SessionChatInputToolbar extends Disposable { } return this._findOwningSession(chat.resource, reader); }); - - /** - * Whether the reflected chat is a subagent (worker) chat. Its pills describe - * the session the subagent was spawned from rather than the subagent's own - * work, so the row stays hidden there. - */ private readonly _isSubagentChat: IObservable = derived(this, reader => this._chat.read(reader)?.origin?.kind === ChatOriginKind.Tool); /** The current turn's diff stats. */ @@ -192,8 +182,7 @@ export class SessionChatInputToolbar extends Disposable { const sessionCustomizations = this._register(instantiationService.createInstance(SessionCustomizations, this._chat, this._session)); this._customizationSections = sessionCustomizations.sections; -<<<<<<< HEAD - const pillsEnabled = derived(reader => this._debugData.read(reader) !== undefined || turnStatusPillsEnabled.read(reader)); + const pillsEnabled = derived(reader => this._debugData.read(reader) !== undefined || (turnStatusPillsEnabled.read(reader) && !this._isSubagentChat.read(reader))); const model: IChatTurnPillsModel = { stats: this._diffStats, artifacts: this._artifactSections, @@ -213,14 +202,6 @@ export class SessionChatInputToolbar extends Disposable { ...metadataPills.pills.read(reader), ...turn.filter(pill => pill.action.id !== CHAT_TURN_CHANGES_PILL_ID), ]; -======= - const pillsVisible = derived(this, reader => this._debugData.read(reader) !== undefined || !this._isSubagentChat.read(reader)); - this._backgroundActivities = this._register(instantiationService.createInstance(SessionBackgroundActivitiesControl, this._session, this._chat, pillsEnabled, constObservable(true))); - const gitHubInfo = derived(this, reader => { - const session = this._session.read(reader); - const workspace = session?.workspace.read(reader); - return workspace?.folders[0]?.gitRepository?.gitHubInfo.read(reader); ->>>>>>> cbb81cdaae0 (Merge pull request #334510 from microsoft/copilot/hide-chat-pills-subagent-chats) }); this._backgroundActivities = this._register(instantiationService.createInstance(SessionBackgroundActivitiesControl, this._session, this._chat, turnStatusPillsEnabled, derived(reader => visibility.isVisible(SessionChatPillKind.Subagents, reader)))); @@ -339,7 +320,6 @@ export class SessionChatInputToolbar extends Disposable { groups.push(menu.withData.map(toggleAction), menu.withoutData.map(toggleAction)); return Separator.join(...groups); }, -<<<<<<< HEAD }); })); @@ -371,27 +351,6 @@ export class SessionChatInputToolbar extends Disposable { this._onDidChangeVisibility.fire(visible); } this._scrollable.scanDomNode(); -======= - }, - pullRequests: { sections: pullRequestSections, icon: pullRequestPresentation.icon }, - issues: { sections: issueSections, icon: issueIcon }, - artifacts: { sections: this._artifactSections }, - references: { sections: this._referenceSections }, - customizations: { sections: this._customizationSections }, - browsers: { sections: this._browsers.sections }, - subagents: { sections: this._backgroundActivities.sections }, - }, SESSION_CHAT_PILL_KINDS)); - const actionRunner = this._register(new SessionActivatingActionRunner(() => this._session.get(), this._sessionsService)); - this._inputPills = this._register(instantiationService.createInstance(ChatInputPills, undefined, { - debugName: 'SessionChatInputToolbar.content', - compact, - enabled: pillsVisible, - sources: constObservable(sources.sources), - offeredKinds: SESSION_CHAT_PILL_KINDS, - context: this._session, - actionRunner, - focusFallback, ->>>>>>> cbb81cdaae0 (Merge pull request #334510 from microsoft/copilot/hide-chat-pills-subagent-chats) })); } diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts index e9f000fb238179..77d7b008df39bf 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts @@ -4,38 +4,27 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -<<<<<<< HEAD -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { CHAT_TURN_ARTIFACT_PILL_ID, CHAT_TURN_CHANGES_PILL_ID } from '../../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; -import { VIEW_SESSION_CHANGES_COMMAND_ID } from '../../../changes/common/changes.js'; -import { OPEN_ISSUE_ACTION_ID, OPEN_PULL_REQUEST_ACTION_ID } from '../../../github/common/types.js'; -import { SessionChatPillKind } from '../../common/sessionChatPills.js'; -import { getSessionChatPillKindForAction, SESSION_BROWSERS_PILL_ID, SESSION_SUBAGENTS_PILL_ID } from '../../browser/sessionChatInputToolbar.js'; -import { SESSION_CUSTOMIZATIONS_PILL_ID } from '../../browser/sessionCustomizations.js'; -======= -import { isManagedHoverTooltipHTMLElement } from '../../../../../base/browser/ui/hover/hover.js'; -import { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { Codicon } from '../../../../../base/common/codicons.js'; import { Event } from '../../../../../base/common/event.js'; -import { constObservable, derived } from '../../../../../base/common/observable.js'; +import { constObservable } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; -import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; -import type { IChatPillEntry } from '../../../../../workbench/browser/chatPills.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IBrowserViewWorkbenchService } from '../../../../../workbench/contrib/browserView/common/browserView.js'; -import { ISessionChatPillVisibilityService } from '../../../../../workbench/contrib/chat/common/sessionChatPills.js'; +import { ChatConfiguration } from '../../../../../workbench/contrib/chat/common/constants.js'; import { workbenchInstantiationService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { ISessionChangesStatsCache } from '../../../../services/sessions/common/sessionChangesStatsCache.js'; -import { ChatOriginKind, SessionStatus, type IChat, type IGitHubIssueRef, type IGitHubPullRequestRef, type ISessionWorkspace } from '../../../../services/sessions/common/session.js'; +import { ChatOriginKind, SessionStatus, type IChat, type ISessionWorkspace } from '../../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; -import { GitHubIssueState, GitHubPullRequestState, type IGitHubIssue, type IGitHubPullRequest } from '../../../github/common/types.js'; -import { buildSessionIssueSections, buildSessionPullRequestSections, computeSessionInputPillStats, SessionChatInputToolbar } from '../../browser/sessionChatInputToolbar.js'; ->>>>>>> cbb81cdaae0 (Merge pull request #334510 from microsoft/copilot/hide-chat-pills-subagent-chats) +import { CHAT_TURN_ARTIFACT_PILL_ID, CHAT_TURN_CHANGES_PILL_ID } from '../../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; +import { VIEW_SESSION_CHANGES_COMMAND_ID } from '../../../changes/common/changes.js'; +import { OPEN_ISSUE_ACTION_ID, OPEN_PULL_REQUEST_ACTION_ID } from '../../../github/common/types.js'; +import { SessionChatPillKind } from '../../common/sessionChatPills.js'; +import { getSessionChatPillKindForAction, SessionChatInputToolbar, SESSION_BROWSERS_PILL_ID, SESSION_SUBAGENTS_PILL_ID } from '../../browser/sessionChatInputToolbar.js'; +import { SESSION_CUSTOMIZATIONS_PILL_ID } from '../../browser/sessionCustomizations.js'; suite('SessionChatInputToolbar', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -64,10 +53,18 @@ suite('SessionChatInputToolbar', () => { test('hides the pills in a subagent chat', () => { const instantiationService = workbenchInstantiationService(undefined, store); + (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(ChatConfiguration.TurnStatusPills, true); const chat = upcastPartial({ resource: URI.parse('chat:main'), title: constObservable('Main chat'), status: constObservable(SessionStatus.InProgress), + lastTurnChanges: constObservable([{ + uri: URI.file('/session-change.ts'), + modifiedUri: URI.file('/session-change.ts'), + insertions: 10, + deletions: 4, + isOutsideWorkspace: false, + }]), }); const subagentChat = upcastPartial({ resource: URI.parse('chat:subagent'), @@ -80,51 +77,63 @@ suite('SessionChatInputToolbar', () => { title: constObservable('Fork'), status: constObservable(SessionStatus.InProgress), origin: { kind: ChatOriginKind.Fork, parentChat: chat.resource }, + lastTurnChanges: constObservable([{ + uri: URI.file('/fork-change.ts'), + modifiedUri: URI.file('/fork-change.ts'), + insertions: 10, + deletions: 4, + isOutsideWorkspace: false, + }]), + }); + const forkSubagentChat = upcastPartial({ + resource: URI.parse('chat:fork-subagent'), + title: constObservable('Fork subagent'), + status: constObservable(SessionStatus.InProgress), + origin: { kind: ChatOriginKind.Tool, parentChat: forkedChat.resource }, }); const session = upcastPartial({ sessionId: 'provider:session', + providerId: 'provider', + sessionType: 'test', resource: URI.parse('session:1'), - chats: constObservable([chat, subagentChat, forkedChat]), + status: constObservable(SessionStatus.InProgress), + isArchived: constObservable(false), + isRead: constObservable(true), + capabilities: constObservable({ supportsMultipleChats: true }), + chats: constObservable([chat, subagentChat, forkedChat, forkSubagentChat]), + activeChat: constObservable(chat), + mainChat: constObservable(chat), + visibleChatTabs: constObservable([chat]), workspace: constObservable(upcastPartial({ folders: [] })), + worktreePending: constObservable(false), changesets: constObservable([]), - changes: constObservable([{ - modifiedUri: URI.file('/session-change.ts'), - insertions: 10, - deletions: 4, - }]), + changes: constObservable([]), + isCreated: constObservable(true), + sticky: constObservable(false), + shouldShowChatTabs: constObservable(false), }); instantiationService.stub(IBrowserViewWorkbenchService, upcastPartial({ onDidChangeBrowserViews: Event.None, getKnownBrowserViews: () => new Map(), })); - instantiationService.stub(ISessionChatPillVisibilityService, upcastPartial({ - readHiddenKinds: () => new Set(), - isVisible: () => true, - hide: () => { }, - toggle: () => { }, - })); instantiationService.stub(ISessionChangesStatsCache, upcastPartial({ get: () => undefined })); instantiationService.stub(ISessionsProvidersService, upcastPartial({ getProvider: () => undefined })); instantiationService.stub(ISessionsService, upcastPartial({ visibleSessions: constObservable([]), activeSession: constObservable(undefined), })); - const toolbar = store.add(instantiationService.createInstance(SessionChatInputToolbar, false, undefined)); - const read = () => ({ - pills: Array.from(toolbar.element.querySelectorAll('.chat-pill-label')).map(label => label.textContent), - visible: toolbar.visible, - }); + const toolbar = store.add(instantiationService.createInstance(SessionChatInputToolbar)); toolbar.setSession(session, chat); - const main = read(); + const main = toolbar.visible; toolbar.setSession(session, subagentChat); - const subagent = read(); + const subagent = toolbar.visible; toolbar.setSession(session, forkedChat); - assert.deepStrictEqual({ main, subagent, fork: read() }, { - main: { pills: ['1 File', 'Subagent'], visible: true }, - subagent: { pills: [], visible: false }, - fork: { pills: ['1 File'], visible: true }, + assert.deepStrictEqual({ main, subagent, fork: toolbar.visible }, { + main: true, + subagent: false, + fork: true, }); }); }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts deleted file mode 100644 index 4d4691978947b4..00000000000000 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts +++ /dev/null @@ -1,566 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { getWindow } from '../../../../../../base/browser/dom.js'; -import { toAction } from '../../../../../../base/common/actions.js'; -import { Codicon } from '../../../../../../base/common/codicons.js'; -import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; -import { constObservable, derived, derivedObservableWithCache, derivedOpts, observableFromEvent, observableSignal, observableSignalFromEvent } from '../../../../../../base/common/observable.js'; -import { basename, isEqual } from '../../../../../../base/common/resources.js'; -import { ThemeIcon } from '../../../../../../base/common/themables.js'; -import { isDefined } from '../../../../../../base/common/types.js'; -import { URI } from '../../../../../../base/common/uri.js'; -import { localize } from '../../../../../../nls.js'; -import { IAgentHostConnectionsService, IAgentHostSessionResolution } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; -import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; -import { resolveChangesetUriTemplate, selectDefaultChangeset, type DefaultChangesetKind } from '../../../../../../platform/agentHost/common/changesetUri.js'; -import { ISessionArtifact, isGitHubArtifactLink, readSessionArtifacts, SessionArtifactType } from '../../../../../../platform/agentHost/common/sessionArtifacts.js'; -import { observableFromSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; -import { Changeset, ChangesetState, ChangesetStatus, ChatOriginKind, DEFAULT_CHAT_ID, getSessionChatResource, getSessionRelatedPullRequestUrls, isSubagentChatUri, parseChatUri, readSessionGitHubState, SessionState, SessionSummaryMeta, StateComponents } from '../../../../../../platform/agentHost/common/state/sessionState.js'; -import { IClipboardService } from '../../../../../../platform/clipboard/common/clipboardService.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; -import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; -import { CHAT_INPUT_PILLS_ROW_HEIGHT, getChatPillEntries, getChatPillResourceLocation, IChatPillEntry, IChatPillSection, type ChatPillsCompactMode } from '../../../../../browser/chatPills.js'; -import { chatChangesStatsEqual, EMPTY_CHAT_CHANGES_STATS, IChatChangesStats } from '../../../../../browser/chatChangesPill.js'; -import { BrowserEditorInput } from '../../../../browserView/common/browserEditorInput.js'; -import { browserViewUrlMatches, BrowserViewSharingState, IBrowserViewWorkbenchService } from '../../../../browserView/common/browserView.js'; -import { IEditorService } from '../../../../../services/editor/common/editorService.js'; -import { computePullRequestIcon, getHighestPriorityPullRequestIcon } from '../../../../../common/chatPullRequest.js'; -import { ISessionChatPillVisibilityService, SessionChatPillKind } from '../../../common/sessionChatPills.js'; -import { CHAT_SUBAGENT_RESOURCE_QUERY_PARAM } from '../../../common/constants.js'; -import { IEditSessionEntryDiff } from '../../../common/editing/chatEditingService.js'; -import { chatPersistentContentVisibleClass, type ChatWidget } from '../../widget/chatWidget.js'; -import { openChatTurnFile, previewKind } from '../../widget/chatTurnPills.js'; -import { openChatFileChanges } from '../../editorChatResponseFileChangesService.js'; -import { ChatInputPills, StandardChatInputPillSources } from '../../chatInputPills.js'; -import { agentHostChangesetFileToEntryDiff } from './agentHostResponseFileChanges.js'; - -const offeredPillKinds: readonly SessionChatPillKind[] = [ - SessionChatPillKind.Changes, - SessionChatPillKind.PullRequests, - SessionChatPillKind.Issues, - SessionChatPillKind.Artifacts, - SessionChatPillKind.References, - SessionChatPillKind.Browsers, -]; - -const artifactIcons: ReadonlyMap = new Map([ - [SessionArtifactType.PullRequest, Codicon.gitPullRequest], - [SessionArtifactType.Issue, Codicon.issues], - [SessionArtifactType.Commit, Codicon.gitCommit], - [SessionArtifactType.Website, Codicon.globe], - [SessionArtifactType.Resource, Codicon.link], -]); - -const artifactSectionOrder: readonly { readonly type: SessionArtifactType; readonly title: string }[] = [ - { type: SessionArtifactType.PullRequest, title: localize('agentHostSessionPills.artifacts.pullRequests', "Pull Requests") }, - { type: SessionArtifactType.Issue, title: localize('agentHostSessionPills.artifacts.issues', "Issues") }, - { type: SessionArtifactType.Commit, title: localize('agentHostSessionPills.artifacts.commits', "Commits") }, - { type: SessionArtifactType.Website, title: localize('agentHostSessionPills.artifacts.websites', "Websites") }, - { type: SessionArtifactType.File, title: localize('agentHostSessionPills.artifacts.files', "Files") }, - { type: SessionArtifactType.Resource, title: localize('agentHostSessionPills.artifacts.resources', "Resources") }, -]; - -export interface IAgentHostSessionPillMetadata { - readonly pullRequestUrls: readonly string[]; - readonly issueUrls: readonly string[]; - readonly artifacts: readonly ISessionArtifact[]; - readonly references: readonly ISessionArtifact[]; -} - -function linkKey(link: string): string { - return link.replace(/\/+$/, '').toLowerCase(); -} - -function dedupeLinks(...groups: readonly (readonly string[] | undefined)[]): string[] { - const seen = new Set(); - const result: string[] = []; - for (const group of groups) { - for (const link of group ?? []) { - const key = linkKey(link); - if (!seen.has(key)) { - seen.add(key); - result.push(link); - } - } - } - return result; -} - -function setsEqual(first: ReadonlySet, second: ReadonlySet): boolean { - return first === second || (first.size === second.size && [...first].every(value => second.has(value))); -} - -function isPromotedArtifact(artifact: ISessionArtifact, type: SessionArtifactType): artifact is ISessionArtifact & { readonly link: string } { - return artifact.isArtifact - && artifact.type === type - && artifact.isGitHub === true - && typeof artifact.link === 'string' - && isGitHubArtifactLink(artifact.link); -} - -/** Partitions Agent Host metadata into dedicated GitHub, artifact, and reference pills. */ -export function getAgentHostSessionPillMetadata(meta: SessionSummaryMeta | undefined): IAgentHostSessionPillMetadata { - const entries = readSessionArtifacts(meta); - const github = readSessionGitHubState(meta); - const artifactPullRequests = entries.filter(entry => isPromotedArtifact(entry, SessionArtifactType.PullRequest)).map(entry => entry.link); - const artifactIssues = entries.filter(entry => isPromotedArtifact(entry, SessionArtifactType.Issue)).map(entry => entry.link); - const pullRequestUrls = dedupeLinks(getSessionRelatedPullRequestUrls(github), artifactPullRequests); - const issueUrls = dedupeLinks(artifactIssues); - const promotedLinks = new Set([...pullRequestUrls, ...issueUrls].map(linkKey)); - const remaining = entries.filter(entry => !entry.link || !promotedLinks.has(linkKey(entry.link))); - return { - pullRequestUrls, - issueUrls, - artifacts: remaining.filter(entry => entry.isArtifact), - references: remaining.filter(entry => !entry.isArtifact), - }; -} - -/** Resolves the session-wide changeset represented by the workbench Changes pill. */ -export function resolveAgentHostSessionChangeset( - backendSession: URI, - changesets: readonly Changeset[] | undefined, - defaultKind?: DefaultChangesetKind, -): { readonly changeset: Changeset; readonly resource: URI } | undefined { - const staticChangesets = changesets?.filter(changeset => !changeset.uriTemplate.includes('{')) ?? []; - const changeset = selectDefaultChangeset(staticChangesets, defaultKind); - const resource = changeset ? parseUri(resolveChangesetUriTemplate(backendSession.toString(), changeset.uriTemplate)) : undefined; - return changeset && resource ? { changeset, resource } : undefined; -} - -/** Resolves the chat channel URI a workbench chat resource addresses. */ -export function getAgentHostSessionChatResource(sessionResource: URI, state: Pick | undefined): URI | undefined { - const explicitChatResource = new URLSearchParams(sessionResource.query).get(CHAT_SUBAGENT_RESOURCE_QUERY_PARAM); - if (explicitChatResource) { - return parseUri(explicitChatResource); - } - return state ? parseUri(getSessionChatResource(state, sessionResource.fragment || DEFAULT_CHAT_ID)?.toString()) : undefined; -} - -/** Returns the workbench chat resources whose browsers belong in the current chat's pill. */ -export function getAgentHostSessionBrowserOwnerIds(sessionResource: URI, state: Pick | undefined): ReadonlySet { - const ownerIds = new Set([sessionResource.toString()]); - if (!state) { - return ownerIds; - } - - const currentChatResource = getAgentHostSessionChatResource(sessionResource, state); - if (!currentChatResource) { - return ownerIds; - } - - for (const chat of state.chats) { - const parentChatResource = chat.origin?.kind === ChatOriginKind.Tool ? parseUri(chat.origin.chat) : undefined; - const parsedChat = parseChatUri(chat.resource); - if (!parentChatResource || !isEqual(parentChatResource, currentChatResource) || !parsedChat) { - continue; - } - - ownerIds.add(sessionResource.with({ fragment: parsedChat.chatId, query: null }).toString()); - const query = new URLSearchParams(sessionResource.query); - query.set(CHAT_SUBAGENT_RESOURCE_QUERY_PARAM, chat.resource); - ownerIds.add(sessionResource.with({ fragment: parsedChat.chatId, query: query.toString() }).toString()); - } - return ownerIds; -} - -function resolutionEquals(first: IAgentHostSessionResolution | undefined, second: IAgentHostSessionResolution | undefined): boolean { - return first === second || (!!first && !!second - && first.connection === second.connection - && first.connectionAuthority === second.connectionAuthority - && first.defaultChangesetKind === second.defaultChangesetKind - && isEqual(first.backendSession, second.backendSession)); -} - -function changesetTargetEquals( - first: { readonly changeset: Changeset; readonly resource: URI } | undefined, - second: { readonly changeset: Changeset; readonly resource: URI } | undefined, -): boolean { - return first === second || (!!first && !!second - && first.changeset.changeKind === second.changeset.changeKind - && first.changeset.label === second.changeset.label - && first.changeset.uriTemplate === second.changeset.uriTemplate - && isEqual(first.resource, second.resource)); -} - -function parseUri(value: string | undefined): URI | undefined { - if (!value) { - return undefined; - } - try { - return URI.parse(value, true); - } catch { - return undefined; - } -} - -function referenceLabel(link: string, kind: 'pullRequest' | 'issue'): string { - const resource = parseUri(link); - const number = resource ? githubReferenceNumber(resource, kind) : undefined; - if (kind === 'pullRequest') { - return number - ? localize('agentHostSessionPills.pullRequest.number', "Pull Request #{0}", number) - : localize('agentHostSessionPills.pullRequest', "Pull Request"); - } - return number - ? localize('agentHostSessionPills.issue.number', "Issue #{0}", number) - : localize('agentHostSessionPills.issue', "Issue"); -} - -function githubReferenceNumber(resource: URI, kind: 'pullRequest' | 'issue'): string | undefined { - const segment = kind === 'pullRequest' ? 'pull' : 'issues'; - return new RegExp(`/${segment}/(?\\d+)(?:/|$)`).exec(resource.path)?.groups?.number; -} - -function websiteKey(url: string): string | undefined { - const parsed = URL.parse(url); - if (!parsed) { - return undefined; - } - const path = parsed.pathname.length > 1 && parsed.pathname.endsWith('/') ? parsed.pathname.slice(0, -1) : parsed.pathname; - return `${parsed.protocol}//${parsed.host}${path}${parsed.search}${parsed.hash}`; -} - -/** Adds Agent Host session metadata pills to a workbench chat input. */ -export class AgentHostSessionInputPills extends Disposable { - - private readonly _browserChanged = observableSignal(this); - private readonly _browserListeners = this._register(new MutableDisposable()); - - constructor( - private readonly _widget: ChatWidget, - compact: ChatPillsCompactMode, - @IAgentHostConnectionsService connectionsService: IAgentHostConnectionsService, - @IBrowserViewWorkbenchService private readonly _browserViewService: IBrowserViewWorkbenchService, - @IClipboardService private readonly _clipboardService: IClipboardService, - @IConfigurationService private readonly _configurationService: IConfigurationService, - @IEditorService private readonly _editorService: IEditorService, - @IInstantiationService instantiationService: IInstantiationService, - @IOpenerService private readonly _openerService: IOpenerService, - @ISessionChatPillVisibilityService visibility: ISessionChatPillVisibilityService, - ) { - super(); - - const sessionResource = observableFromEvent(this, this._widget.onDidChangeViewModel, () => this._widget.viewModel?.sessionResource); - const sessionResolutionChanged = observableSignalFromEvent(this, connectionsService.onDidChangeSessionResolution); - const resolution = derivedOpts({ owner: this, equalsFn: resolutionEquals }, reader => { - sessionResolutionChanged.read(reader); - const resource = sessionResource.read(reader); - return resource ? connectionsService.resolveSessionResource(resource) : undefined; - }); - const sessionStateSource = derived(this, reader => { - const current = resolution.read(reader); - if (!current) { - return constObservable(undefined); - } - const subscription = reader.store.add(current.connection.getSubscription(StateComponents.Session, current.backendSession, 'AgentHostSessionInputPills')); - return observableFromSubscription(this, subscription.object); - }); - const sessionState = derived(this, reader => sessionStateSource.read(reader).read(reader)); - // A subagent (worker) chat inherits the session-wide pills, where they read - // as the subagent's own work, so the row stays hidden there. - const subagentChat = derived(this, reader => { - const resource = sessionResource.read(reader); - const chatResource = resource ? getAgentHostSessionChatResource(resource, sessionState.read(reader)) : undefined; - return !!chatResource && isSubagentChatUri(chatResource); - }); - const pillsVisible = derived(this, reader => !subagentChat.read(reader)); - const changesetTarget = derivedOpts({ owner: this, equalsFn: changesetTargetEquals }, reader => { - const currentResolution = resolution.read(reader); - return currentResolution - ? resolveAgentHostSessionChangeset(currentResolution.backendSession, sessionState.read(reader)?.changesets, currentResolution.defaultChangesetKind) - : undefined; - }); - const changesetStateSource = derived(this, reader => { - const currentResolution = resolution.read(reader); - const resource = changesetTarget.read(reader)?.resource; - if (!currentResolution || !resource) { - return constObservable(undefined); - } - const subscription = reader.store.add(currentResolution.connection.getSubscription(StateComponents.Changeset, resource, 'AgentHostSessionInputPills')); - return observableFromSubscription(this, subscription.object); - }); - const changesetFiles = derivedObservableWithCache<{ readonly connectionAuthority: string; readonly resource: URI; readonly files: ChangesetState['files'] } | undefined>(this, (reader, lastValue) => { - const currentResolution = resolution.read(reader); - const target = changesetTarget.read(reader); - if (!currentResolution || !target) { - return undefined; - } - const state = changesetStateSource.read(reader).read(reader); - if (!state) { - return lastValue?.connectionAuthority === currentResolution.connectionAuthority && isEqual(lastValue.resource, target.resource) ? lastValue : undefined; - } - if (state.status !== ChangesetStatus.Ready && lastValue?.connectionAuthority === currentResolution.connectionAuthority && isEqual(lastValue.resource, target.resource)) { - return lastValue; - } - return { connectionAuthority: currentResolution.connectionAuthority, resource: target.resource, files: state.files }; - }); - const changes = derived(this, reader => { - const currentResolution = resolution.read(reader); - const files = changesetFiles.read(reader)?.files; - if (!currentResolution || !files) { - return []; - } - return files - .map(file => agentHostChangesetFileToEntryDiff(file, currentResolution.connectionAuthority)) - .filter(isDefined); - }); - const changeStats = derivedOpts({ owner: this, equalsFn: chatChangesStatsEqual }, reader => { - const diffs = changes.read(reader); - return diffs.length === 0 - ? EMPTY_CHAT_CHANGES_STATS - : { - files: diffs.length, - insertions: diffs.reduce((total, diff) => total + diff.added, 0), - deletions: diffs.reduce((total, diff) => total + diff.removed, 0), - }; - }); - const metadata = derived(this, reader => getAgentHostSessionPillMetadata(sessionState.read(reader)?._meta)); - const gitHubState = derived(this, reader => readSessionGitHubState(sessionState.read(reader)?._meta)); - - this._register(this._browserViewService.onDidChangeBrowserViews(() => this._refreshBrowserListeners())); - this._refreshBrowserListeners(); - const browserInputs = derived(this, reader => { - this._browserChanged.read(reader); - const resource = sessionResource.read(reader); - if (!resource || !resolution.read(reader)) { - return []; - } - const ownerIds = getAgentHostSessionBrowserOwnerIds(resource, sessionState.read(reader)); - return [...this._browserViewService.getKnownBrowserViews().values()] - .filter(input => input.model?.owner.type === 'agent' && ownerIds.has(input.model.owner.sessionId)); - }); - const browserUrls = derivedOpts>({ owner: this, equalsFn: setsEqual }, reader => { - return visibility.isVisible(SessionChatPillKind.Browsers, reader) - ? new Set(browserInputs.read(reader).map(input => input.url).filter(isDefined)) - : new Set(); - }); - - const pullRequestSections = derived(this, reader => this._buildReferenceSections(metadata.read(reader).pullRequestUrls, 'pullRequest', gitHubState.read(reader))); - const pullRequestIcon = derived(this, reader => { - const icons = getChatPillEntries(pullRequestSections.read(reader)).map(entry => entry.icon); - return getHighestPriorityPullRequestIcon(icons) ?? computePullRequestIcon('open'); - }); - const issueSections = derived(this, reader => this._buildReferenceSections(metadata.read(reader).issueUrls, 'issue')); - const artifactSections = derived(this, reader => { - const currentResolution = resolution.read(reader); - return currentResolution - ? this._buildArtifactSections(metadata.read(reader).artifacts, browserUrls.read(reader), currentResolution) - : []; - }); - const referenceSections = derived(this, reader => { - const currentResolution = resolution.read(reader); - return currentResolution - ? this._buildArtifactSections(metadata.read(reader).references, browserUrls.read(reader), currentResolution) - : []; - }); - const browserSections = derived(this, reader => { - const entries = browserInputs.read(reader).map(input => this._browserEntry(input, sessionResource.read(reader))); - return entries.length > 0 ? [{ title: localize('agentHostSessionPills.browsers.section', "Browsers"), entries }] : []; - }); - - const sources = this._register(instantiationService.createInstance(StandardChatInputPillSources, { - changes: { - stats: changeStats, - label: derived(this, reader => changesetTarget.read(reader)?.changeset.label ?? localize('agentHostSessionPills.changes', "Changes")), - open: () => this._openChanges(changesetTarget.get()?.changeset.label ?? localize('agentHostSessionPills.changesEditor', "Session Changes"), changes.get()), - }, - pullRequests: { sections: pullRequestSections, icon: pullRequestIcon }, - issues: { sections: issueSections }, - artifacts: { sections: artifactSections }, - references: { sections: referenceSections }, - browsers: { sections: browserSections }, - }, offeredPillKinds)); - const inputPills = this._register(instantiationService.createInstance(ChatInputPills, this._widget.inputPart.persistentContentContainerElement, { - debugName: 'AgentHostSessionInputPills.content', - compact, - targetWindow: getWindow(this._widget.inputPart.persistentContentContainerElement), - enabled: pillsVisible, - sources: constObservable(sources.sources), - offeredKinds: offeredPillKinds, - ariaLabel: localize('agentHostSessionPills.ariaLabel', "Session status"), - focusFallback: () => this._widget.focusInput(), - })); - inputPills.element.classList.add('agent-host-session-input-pills'); - - this._register(this._widget.inputPart.registerChatPetHorizontalPlatformProvider({ - onDidChange: inputPills.onDidChange, - getElements: () => inputPills.getPillElements(), - })); - const updateVisibility = (visible: boolean) => { - this._widget.inputPart.persistentContentContainerElement.classList.toggle(chatPersistentContentVisibleClass, visible); - this._widget.setPersistentContentHeight(visible ? CHAT_INPUT_PILLS_ROW_HEIGHT : undefined); - }; - this._register(inputPills.onDidChangeVisibility(updateVisibility)); - updateVisibility(inputPills.visible); - } - - private _buildReferenceSections(links: readonly string[], kind: 'pullRequest' | 'issue', gitHubState?: ReturnType): readonly IChatPillSection[] { - const entries = links.map(link => { - const resource = parseUri(link); - if (!resource) { - return undefined; - } - const number = githubReferenceNumber(resource, kind); - const label = referenceLabel(link, kind); - const pullRequestState = kind === 'pullRequest' - && gitHubState?.pullRequestState - && gitHubState.pullRequestStateUrl - && linkKey(gitHubState.pullRequestStateUrl) === linkKey(link) - ? gitHubState.pullRequestState - : 'open'; - return { - id: linkKey(link), - label, - ...(kind === 'pullRequest' && number ? { pillLabel: `#${number}` } : {}), - icon: kind === 'pullRequest' ? computePullRequestIcon(pullRequestState) : Codicon.issues, - toolbarActions: [toAction({ - id: `chatInputPills.copy.${kind}.${linkKey(link)}`, - label: kind === 'pullRequest' - ? localize('agentHostSessionPills.copyPullRequest', "Copy Pull Request URL") - : localize('agentHostSessionPills.copyIssue', "Copy Issue URL"), - class: ThemeIcon.asClassName(Codicon.copy), - run: () => this._clipboardService.writeText(resource.toString(true)), - })], - ...getChatPillResourceLocation(resource, label), - open: () => this._openExternal(resource), - } satisfies IChatPillEntry; - }).filter(isDefined); - const title = kind === 'pullRequest' - ? localize('agentHostSessionPills.pullRequests.section', "Pull Requests") - : localize('agentHostSessionPills.issues.section', "Issues"); - return entries.length > 0 ? [{ title, entries }] : []; - } - - private _buildArtifactSections(entries: readonly ISessionArtifact[], browserUrls: ReadonlySet, resolution: IAgentHostSessionResolution): readonly IChatPillSection[] { - const browserKeys = new Set([...browserUrls].map(websiteKey).filter(isDefined)); - const entriesByType = new Map(); - for (const artifact of entries) { - if (artifact.type === SessionArtifactType.Website && artifact.link) { - const key = websiteKey(artifact.link); - if (key && browserKeys.has(key)) { - continue; - } - } - const entry = this._artifactEntry(artifact, resolution); - if (entry) { - const typeEntries = entriesByType.get(artifact.type) ?? []; - typeEntries.push(entry); - entriesByType.set(artifact.type, typeEntries); - } - } - return artifactSectionOrder.flatMap(({ type, title }) => { - const sectionEntries = entriesByType.get(type); - return sectionEntries?.length ? [{ title, entries: sectionEntries }] : []; - }); - } - - private _artifactEntry(artifact: ISessionArtifact, resolution: IAgentHostSessionResolution): IChatPillEntry | undefined { - if (artifact.type === SessionArtifactType.File || artifact.type === SessionArtifactType.Resource) { - const artifactResource = parseUri(artifact.uri); - if (!artifactResource) { - return undefined; - } - const resource = artifact.type === SessionArtifactType.File - ? toAgentHostUri(artifactResource, resolution.connectionAuthority) - : artifactResource; - const label = artifact.type === SessionArtifactType.File ? basename(resource) : artifact.label; - return { - id: artifact.id, - label, - ...(artifact.type === SessionArtifactType.File ? { resource } : { icon: Codicon.link }), - ...getChatPillResourceLocation(resource, label), - open: () => this._openResource(resource), - }; - } - - const link = parseUri(artifact.link); - const icon = artifactIcons.get(artifact.type) ?? Codicon.archive; - if (link) { - const copyAction = artifact.type === SessionArtifactType.Commit && artifact.commitHash - ? [toAction({ - id: 'chat.agentHost.sessionPills.copyCommitHash', - label: localize('agentHostSessionPills.copyCommitHash', "Copy Commit Hash"), - class: ThemeIcon.asClassName(Codicon.copy), - run: () => this._clipboardService.writeText(artifact.commitHash!), - })] - : undefined; - return { - id: artifact.id, - label: artifact.label, - icon, - ...(copyAction ? { toolbarActions: copyAction } : {}), - ...getChatPillResourceLocation(link, artifact.label), - open: () => this._openExternal(link), - }; - } - if (artifact.type === SessionArtifactType.Commit && artifact.commitHash) { - return { - id: artifact.id, - label: artifact.label, - icon, - ariaLabel: localize('agentHostSessionPills.copyCommit', "Copy commit hash for {0}", artifact.label), - tooltip: artifact.commitHash, - open: () => { void this._clipboardService.writeText(artifact.commitHash!); }, - }; - } - return undefined; - } - - private _browserEntry(input: BrowserEditorInput, sessionResource: URI | undefined): IChatPillEntry { - const label = input.title?.trim() || localize('agentHostSessionPills.browser', "Browser"); - return { - id: input.id, - label, - icon: Codicon.globe, - open: () => { void this._openBrowser(input, sessionResource); }, - }; - } - - private async _openBrowser(input: BrowserEditorInput, sessionResource: URI | undefined): Promise { - const url = input.url; - const shared = url - ? [...this._browserViewService.getContextualBrowserViews({ activeSessionId: sessionResource?.toString() }).values()] - .filter(candidate => candidate.model?.sharingState === BrowserViewSharingState.Shared && browserViewUrlMatches(candidate.url, url)) - : []; - const target = input.model?.sharingState === BrowserViewSharingState.Shared || !url - ? input - : shared.find(candidate => candidate.url === url) ?? shared.at(0) ?? input; - const existing = this._editorService.findEditors(target.resource) - .find(identifier => identifier.editor instanceof BrowserEditorInput && identifier.editor.id === target.id); - const targetGroup = existing?.groupId ?? await this._browserViewService.getPreferredGroup(); - await this._editorService.openEditor(target, undefined, targetGroup); - } - - private _openChanges(label: string, diffs: readonly IEditSessionEntryDiff[]): void { - if (diffs.length > 0) { - openChatFileChanges(this._editorService, label, diffs); - } - } - - private _openExternal(resource: URI): void { - void this._openerService.open(resource, { openExternal: true, allowContributedOpeners: true, fromUserGesture: true }); - } - - private _openResource(resource: URI): void { - const kind = previewKind(resource); - if (kind) { - void openChatTurnFile({ uri: resource, kind, created: false }, this._openerService, this._configurationService); - return; - } - void this._openerService.open(resource, { fromUserGesture: true }); - } - - private _refreshBrowserListeners(): void { - const store = new DisposableStore(); - this._browserListeners.value = store; - for (const input of this._browserViewService.getKnownBrowserViews().values()) { - store.add(input.onDidChangeLabel(() => this._browserChanged.trigger(undefined))); - } - this._browserChanged.trigger(undefined); - } -} diff --git a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts deleted file mode 100644 index c8361279657426..00000000000000 --- a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts +++ /dev/null @@ -1,714 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { Emitter, Event } from '../../../../../../base/common/event.js'; -import { Disposable, toDisposable, type IReference } from '../../../../../../base/common/lifecycle.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 } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; -import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; -import { ChangesetKind } from '../../../../../../platform/agentHost/common/changesetUri.js'; -import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; -import { ISessionArtifact, SessionArtifactType, withSessionArtifacts } from '../../../../../../platform/agentHost/common/sessionArtifacts.js'; -import { buildDefaultChatUri, buildSubagentChatUri, Changeset, ChangesetState, ChangesetStatus, ChatOriginKind, ComponentToState, SessionState, StateComponents, withSessionGitHubState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; -import { IClipboardService } from '../../../../../../platform/clipboard/common/clipboardService.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; -import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; -import { BrowserEditorInput } from '../../../../browserView/common/browserEditorInput.js'; -import { IBrowserViewModel, IBrowserViewWorkbenchService } from '../../../../browserView/common/browserView.js'; -import { IEditorService } from '../../../../../services/editor/common/editorService.js'; -import { CHAT_SUBAGENT_RESOURCE_QUERY_PARAM } from '../../../common/constants.js'; -import { type IChatWidgetViewModelChangeEvent } from '../../../browser/chat.js'; -import { AgentHostSessionInputPills, getAgentHostSessionBrowserOwnerIds, getAgentHostSessionPillMetadata, resolveAgentHostSessionChangeset } from '../../../browser/agentSessions/agentHost/agentHostSessionInputPills.js'; -import { ISessionChatPillVisibilityService, SessionChatPillKind } from '../../../common/sessionChatPills.js'; -import { chatPersistentContentVisibleClass, ChatWidget } from '../../../browser/widget/chatWidget.js'; -import { ChatInputPart } from '../../../browser/widget/input/chatInputPart.js'; -import { ChatViewModel } from '../../../common/model/chatViewModel.js'; - -class StaticAgentConnection extends mock() { - readonly requested: Array<{ kind: StateComponents; resource: URI }> = []; - private readonly emitters = new Map>(); - - constructor(private readonly values: ReadonlyMap) { - super(); - } - - override getSubscription(kind: T, resource: URI): IReference> { - this.requested.push({ kind, resource }); - let emitter = this.emitters.get(kind); - if (!emitter) { - emitter = new Emitter(); - this.emitters.set(kind, emitter); - } - const values = this.values; - return { - object: { - get value() { return values.get(kind) as ComponentToState[T]; }, - get verifiedValue() { return values.get(kind) as ComponentToState[T]; }, - onDidChange: emitter.event as Event, - onWillApplyAction: Event.None, - onDidApplyAction: Event.None, - }, - dispose: () => { }, - }; - } - - setState(kind: StateComponents, value: SessionState | ChangesetState): void { - (this.values as Map).set(kind, value); - this.emitters.get(kind)?.fire(value); - } -} - -suite('AgentHostSessionInputPills', () => { - const store = ensureNoDisposablesAreLeakedInTestSuite(); - - test('partitions GitHub links, artifacts, and references without duplication', () => { - const entries: readonly ISessionArtifact[] = [ - { id: 'created-pr', type: SessionArtifactType.PullRequest, label: 'Created PR', link: 'https://github.com/microsoft/vscode/pull/2', isGitHub: true, isArtifact: true }, - { id: 'duplicate-pr', type: SessionArtifactType.PullRequest, label: 'Existing PR', link: 'https://github.com/microsoft/vscode/pull/1/', isGitHub: true, isArtifact: false }, - { id: 'created-issue', type: SessionArtifactType.Issue, label: 'Created Issue', link: 'https://github.com/microsoft/vscode/issues/3', isGitHub: true, isArtifact: true }, - { id: 'issue-reference', type: SessionArtifactType.Issue, label: 'Related Issue', link: 'https://github.com/microsoft/vscode/issues/4', isGitHub: true, isArtifact: false }, - { id: 'website', type: SessionArtifactType.Website, label: 'Preview', link: 'https://example.com', isArtifact: true }, - { id: 'resource', type: SessionArtifactType.Resource, label: 'Docs', uri: 'https://example.com/docs', isArtifact: false }, - ]; - const meta = withSessionGitHubState( - withSessionArtifacts(undefined, entries), - { - pullRequestUrls: ['https://github.com/microsoft/vscode/pull/1'], - }, - ); - - const metadata = getAgentHostSessionPillMetadata(meta); - - assert.deepStrictEqual({ - pullRequestUrls: metadata.pullRequestUrls, - issueUrls: metadata.issueUrls, - artifactIds: metadata.artifacts.map(artifact => artifact.id), - referenceIds: metadata.references.map(reference => reference.id), - }, { - pullRequestUrls: [ - 'https://github.com/microsoft/vscode/pull/1', - 'https://github.com/microsoft/vscode/pull/2', - ], - issueUrls: ['https://github.com/microsoft/vscode/issues/3'], - artifactIds: ['website'], - referenceIds: ['issue-reference', 'resource'], - }); - }); - - test('resolves the configured session changeset and ignores templated entries', () => { - const backendSession = URI.parse('ahp-session:/session'); - const changesets: readonly Changeset[] = [ - { label: 'Last Turn', uriTemplate: 'changeset/turn/{turnId}', changeKind: ChangesetKind.Turn }, - { label: 'Session Changes', uriTemplate: 'changeset/session', changeKind: ChangesetKind.Session }, - { label: 'Branch Changes', uriTemplate: 'changeset/branch', changeKind: ChangesetKind.Branch }, - ]; - - assert.deepStrictEqual({ - preferred: resolveAgentHostSessionChangeset(backendSession, changesets, ChangesetKind.Session), - fallback: resolveAgentHostSessionChangeset(backendSession, changesets.slice(0, 2), ChangesetKind.Branch), - turnOnly: resolveAgentHostSessionChangeset(backendSession, changesets.slice(0, 1), ChangesetKind.Session), - }, { - preferred: { - changeset: changesets[1], - resource: URI.parse('ahp-session:/session/changeset/session'), - }, - fallback: { - changeset: changesets[1], - resource: URI.parse('ahp-session:/session/changeset/session'), - }, - turnOnly: undefined, - }); - }); - - test('includes browsers owned by direct tool-origin child chats', () => { - const sessionResource = URI.parse('vscode-chat-session://agent-host/session'); - const backendSession = URI.parse('ahp-session://host/session'); - const parentChat = buildDefaultChatUri(backendSession); - const childChat = buildSubagentChatUri(backendSession, 'tool-1'); - const unrelatedChildChat = buildSubagentChatUri(backendSession, 'tool-2'); - const childChatId = 'subagent/tool-1'; - const stateWithoutChild = { - defaultChat: parentChat, - chats: [], - } as unknown as SessionState; - const stateWithChild = { - defaultChat: parentChat, - chats: [{ - resource: childChat, - origin: { kind: ChatOriginKind.Tool, chat: parentChat, toolCallId: 'tool-1' }, - }, { - resource: unrelatedChildChat, - origin: { kind: ChatOriginKind.Tool, chat: buildDefaultChatUri(URI.parse('ahp-session://host/other')), toolCallId: 'tool-2' }, - }], - } as unknown as SessionState; - const explicitQuery = new URLSearchParams(); - explicitQuery.set(CHAT_SUBAGENT_RESOURCE_QUERY_PARAM, childChat); - const canonicalChildResource = sessionResource.with({ fragment: childChatId, query: null }); - const explicitChildResource = sessionResource.with({ fragment: childChatId, query: explicitQuery.toString() }); - - const before = getAgentHostSessionBrowserOwnerIds(sessionResource, stateWithoutChild); - const after = getAgentHostSessionBrowserOwnerIds(sessionResource, stateWithChild); - - assert.deepStrictEqual({ - before: [...before], - after: [...after], - hasCanonicalChild: after.has(canonicalChildResource.toString()), - hasExplicitChild: after.has(explicitChildResource.toString()), - hasUnrelatedChild: after.has(sessionResource.with({ fragment: 'subagent/tool-2', query: null }).toString()), - }, { - before: [sessionResource.toString()], - after: [ - sessionResource.toString(), - canonicalChildResource.toString(), - explicitChildResource.toString(), - ], - hasCanonicalChild: true, - hasExplicitChild: true, - hasUnrelatedChild: false, - }); - }); - - test('does not render pills for a Local chat input', () => { - const instantiationService = workbenchInstantiationService(undefined, store); - const sessionResource = URI.parse('vscode-chat-session://local/session'); - const persistentContent = document.createElement('div'); - document.body.appendChild(persistentContent); - store.add(toDisposable(() => persistentContent.remove())); - let persistentContentHeight: number | undefined; - const widget = upcastPartial({ - inputPart: upcastPartial({ - persistentContentContainerElement: persistentContent, - registerChatPetHorizontalPlatformProvider: () => Disposable.None, - }), - onDidChangeViewModel: Event.None, - viewModel: upcastPartial({ sessionResource }), - setPersistentContentHeight: height => persistentContentHeight = height, - }); - const connectionsService = upcastPartial({ - onDidChangeConnections: Event.None, - onDidChangeSessionResolution: Event.None, - connections: [], - resolveSessionResource: () => undefined, - }); - const browserViewService = upcastPartial({ - onDidChangeBrowserViews: Event.None, - getKnownBrowserViews: () => new Map(), - }); - const visibility = upcastPartial({ - readHiddenKinds: () => new Set(), - isVisible: () => true, - hide: () => { }, - toggle: () => { }, - }); - instantiationService.stub(ISessionChatPillVisibilityService, visibility); - const [clipboardService, configurationService, editorService, openerService] = instantiationService.invokeFunction(accessor => [ - accessor.get(IClipboardService), - accessor.get(IConfigurationService), - accessor.get(IEditorService), - accessor.get(IOpenerService), - ] as const); - - store.add(new AgentHostSessionInputPills( - widget, - false, - connectionsService, - browserViewService, - clipboardService, - configurationService, - editorService, - instantiationService, - openerService, - visibility, - )); - const row = persistentContent.querySelector('.agent-host-session-input-pills'); - - assert.deepStrictEqual({ - hidden: row?.classList.contains('hidden'), - pillCount: row?.querySelectorAll('.chat-pill-item').length, - persistentContentVisible: persistentContent.classList.contains(chatPersistentContentVisibleClass), - persistentContentHeight, - }, { - hidden: true, - pillCount: 0, - persistentContentVisible: false, - persistentContentHeight: undefined, - }); - }); - - test('marks floating persistent content visible when Agent Host pills have data', () => { - const instantiationService = workbenchInstantiationService(undefined, store); - const sessionResource = URI.parse('agent-host-copilot:/session'); - const backendSession = URI.parse('copilot:/session'); - const connection = new StaticAgentConnection(new Map([ - [StateComponents.Session, { - defaultChat: buildDefaultChatUri(backendSession), - chats: [], - changesets: [{ label: 'Branch Changes', uriTemplate: 'changeset/branch', changeKind: ChangesetKind.Branch }], - } as unknown as SessionState], - [StateComponents.Changeset, { - status: ChangesetStatus.Ready, - files: [{ - id: 'change', - edit: { - after: { uri: URI.file('/changed.ts').toString(), content: { uri: 'git-blob://after' } }, - diff: { added: 3, removed: 1 }, - }, - }], - } as unknown as ChangesetState], - ])); - const otherConnection = new StaticAgentConnection(new Map([ - [StateComponents.Session, { - defaultChat: buildDefaultChatUri(backendSession), - chats: [], - changesets: [{ label: 'Branch Changes', uriTemplate: 'changeset/branch', changeKind: ChangesetKind.Branch }], - } as unknown as SessionState], - [StateComponents.Changeset, { - status: ChangesetStatus.Computing, - files: [], - } as unknown as ChangesetState], - ])); - const persistentContent = document.createElement('div'); - document.body.appendChild(persistentContent); - store.add(toDisposable(() => persistentContent.remove())); - let persistentContentHeight: number | undefined; - const widget = upcastPartial({ - inputPart: upcastPartial({ - persistentContentContainerElement: persistentContent, - registerChatPetHorizontalPlatformProvider: () => Disposable.None, - }), - onDidChangeViewModel: Event.None, - viewModel: upcastPartial({ sessionResource }), - setPersistentContentHeight: height => persistentContentHeight = height, - }); - const resolutionChanged = new Emitter(); - let currentConnection = connection; - let connectionAuthority = 'local'; - const connectionsService = upcastPartial({ - onDidChangeConnections: Event.None, - onDidChangeSessionResolution: resolutionChanged.event, - connections: [], - resolveSessionResource: () => ({ - connection: currentConnection, - connectionAuthority, - backendSession, - defaultChangesetKind: ChangesetKind.Branch, - }), - }); - const browserViewService = upcastPartial({ - onDidChangeBrowserViews: Event.None, - getKnownBrowserViews: () => new Map(), - }); - const visibility = upcastPartial({ - readHiddenKinds: () => new Set(), - isVisible: () => true, - hide: () => { }, - toggle: () => { }, - }); - instantiationService.stub(ISessionChatPillVisibilityService, visibility); - const [clipboardService, configurationService, editorService, openerService] = instantiationService.invokeFunction(accessor => [ - accessor.get(IClipboardService), - accessor.get(IConfigurationService), - accessor.get(IEditorService), - accessor.get(IOpenerService), - ] as const); - - store.add(new AgentHostSessionInputPills( - widget, - false, - connectionsService, - browserViewService, - clipboardService, - configurationService, - editorService, - instantiationService, - openerService, - visibility, - )); - const row = persistentContent.querySelector('.agent-host-session-input-pills'); - const button = row?.querySelector('.chat-pill-button'); - connection.setState(StateComponents.Changeset, { - status: ChangesetStatus.Computing, - files: [], - } as ChangesetState); - const recomputing = { - hidden: row?.classList.contains('hidden'), - buttonPreserved: row?.querySelector('.chat-pill-button') === button, - persistentContentVisible: persistentContent.classList.contains(chatPersistentContentVisibleClass), - persistentContentHeight, - }; - connection.setState(StateComponents.Changeset, { - status: ChangesetStatus.Ready, - files: [], - } as ChangesetState); - const readyEmpty = { - hidden: row?.classList.contains('hidden'), - persistentContentVisible: persistentContent.classList.contains(chatPersistentContentVisibleClass), - persistentContentHeight, - }; - connection.setState(StateComponents.Changeset, { - status: ChangesetStatus.Ready, - files: [{ - id: 'change', - edit: { - after: { uri: URI.file('/changed.ts').toString(), content: { uri: 'git-blob://after' } }, - diff: { added: 3, removed: 1 }, - }, - }], - } as ChangesetState); - connection.setState(StateComponents.Changeset, { - status: ChangesetStatus.Computing, - files: [], - } as ChangesetState); - currentConnection = otherConnection; - connectionAuthority = 'remote'; - resolutionChanged.fire(); - - assert.deepStrictEqual({ - recomputing, - readyEmpty, - otherConnection: { - hidden: row?.classList.contains('hidden'), - persistentContentVisible: persistentContent.classList.contains(chatPersistentContentVisibleClass), - persistentContentHeight, - }, - subscriptions: [...new Map(connection.requested.map(request => { - const value = { kind: request.kind, resource: request.resource.toString() }; - return [`${value.kind}:${value.resource}`, value]; - })).values()], - }, { - recomputing: { - hidden: false, - buttonPreserved: true, - persistentContentVisible: true, - persistentContentHeight: 28, - }, - readyEmpty: { - hidden: true, - persistentContentVisible: false, - persistentContentHeight: undefined, - }, - otherConnection: { - hidden: true, - persistentContentVisible: false, - persistentContentHeight: undefined, - }, - subscriptions: [{ - kind: StateComponents.Session, - resource: 'copilot:/session', - }, { - kind: StateComponents.Changeset, - resource: 'copilot:/session/changeset/branch', - }], - }); - }); - - test('matches the Agents Window pull request summary presentation', () => { - const instantiationService = workbenchInstantiationService(undefined, store); - const sessionResource = URI.parse('agent-host-copilot:/session'); - const backendSession = URI.parse('copilot:/session'); - const connection = new StaticAgentConnection(new Map([ - [StateComponents.Session, { - defaultChat: buildDefaultChatUri(backendSession), - chats: [], - _meta: withSessionGitHubState(undefined, { - pullRequestUrls: [ - 'https://github.com/microsoft/vscode/pull/1', - 'https://github.com/microsoft/vscode/pull/2', - 'https://github.com/microsoft/vscode/pull/3', - ], - // Only pull request #1 is merged; the other entries must retain their open state. - pullRequestState: 'merged', - pullRequestStateUrl: 'https://github.com/microsoft/vscode/pull/1', - }), - } as unknown as SessionState], - ])); - const persistentContent = document.createElement('div'); - document.body.appendChild(persistentContent); - store.add(toDisposable(() => persistentContent.remove())); - const widget = upcastPartial({ - inputPart: upcastPartial({ - persistentContentContainerElement: persistentContent, - registerChatPetHorizontalPlatformProvider: () => Disposable.None, - }), - onDidChangeViewModel: Event.None, - viewModel: upcastPartial({ sessionResource }), - setPersistentContentHeight: () => { }, - }); - const connectionsService = upcastPartial({ - onDidChangeConnections: Event.None, - onDidChangeSessionResolution: Event.None, - connections: [{ authority: 'local', address: undefined, name: 'Local', isAmbient: true, connection }], - resolveSessionResource: () => ({ connection, connectionAuthority: 'local', backendSession }), - }); - const browserViewService = upcastPartial({ - onDidChangeBrowserViews: Event.None, - getKnownBrowserViews: () => new Map(), - }); - const visibility = upcastPartial({ - readHiddenKinds: () => new Set(), - isVisible: () => true, - hide: () => { }, - toggle: () => { }, - }); - instantiationService.stub(ISessionChatPillVisibilityService, visibility); - const [clipboardService, configurationService, editorService, openerService] = instantiationService.invokeFunction(accessor => [ - accessor.get(IClipboardService), - accessor.get(IConfigurationService), - accessor.get(IEditorService), - accessor.get(IOpenerService), - ] as const); - - store.add(new AgentHostSessionInputPills( - widget, - false, - connectionsService, - browserViewService, - clipboardService, - configurationService, - editorService, - instantiationService, - openerService, - visibility, - )); - const button = persistentContent.querySelector('.chat-dropdown-pill-button'); - const icon = button?.querySelector('.chat-pill-icon'); - const multiple = { - button, - label: button?.querySelector('.chat-pill-label')?.textContent, - iconClass: icon?.classList.contains('codicon-git-pull-request'), - iconColor: icon?.style.color, - hasChevron: button?.querySelector('.chat-pill-chevron') !== null, - }; - connection.setState(StateComponents.Session, { - defaultChat: buildDefaultChatUri(backendSession), - chats: [], - _meta: withSessionGitHubState(undefined, { - pullRequestUrls: ['https://github.com/microsoft/vscode/pull/1'], - pullRequestState: 'merged', - pullRequestStateUrl: 'https://github.com/microsoft/vscode/pull/1', - }), - } as unknown as SessionState); - const singleButton = persistentContent.querySelector('.chat-dropdown-pill-button'); - const singleIcon = singleButton?.querySelector('.chat-pill-icon'); - - assert.deepStrictEqual({ - multiple, - single: { - buttonPreserved: singleButton === multiple.button, - label: singleButton?.querySelector('.chat-pill-label')?.textContent, - iconClass: singleIcon?.classList.contains('codicon-git-pull-request-done'), - iconColor: singleIcon?.style.color, - hasChevron: singleButton?.querySelector('.chat-pill-chevron') !== null, - }, - }, { - multiple: { - button, - label: '3 Pull Requests', - iconClass: true, - iconColor: 'var(--vscode-charts-green)', - hasChevron: true, - }, - single: { - buttonPreserved: true, - label: '#1', - iconClass: true, - iconColor: 'var(--vscode-charts-purple)', - hasChevron: false, - }, - }); - }); - - test('keeps a matching website artifact visible while Browsers is hidden', () => { - const instantiationService = workbenchInstantiationService(undefined, store); - const sessionResource = URI.parse('agent-host-copilot:/session'); - const backendSession = URI.parse('copilot:/session'); - const website = URI.parse('https://example.com/preview'); - const connection = new StaticAgentConnection(new Map([ - [StateComponents.Session, { - defaultChat: buildDefaultChatUri(backendSession), - chats: [], - _meta: withSessionArtifacts(undefined, [{ - id: 'preview', - type: SessionArtifactType.Website, - label: 'Preview', - link: website.toString(), - isArtifact: true, - }]), - } as unknown as SessionState], - ])); - const browserModel = upcastPartial({ - owner: { type: 'agent', sessionId: sessionResource.toString() }, - }); - const browser = new class extends mock() { - override get id(): string { return 'preview-browser'; } - override get model(): IBrowserViewModel { return browserModel; } - override get url(): string { return website.toString(); } - override get title(): string { return 'Preview'; } - override readonly onDidChangeLabel = Event.None; - }(); - const persistentContent = document.createElement('div'); - document.body.appendChild(persistentContent); - store.add(toDisposable(() => persistentContent.remove())); - const widget = upcastPartial({ - inputPart: upcastPartial({ - persistentContentContainerElement: persistentContent, - registerChatPetHorizontalPlatformProvider: () => Disposable.None, - }), - onDidChangeViewModel: Event.None, - viewModel: upcastPartial({ sessionResource }), - setPersistentContentHeight: () => { }, - }); - const connectionsService = upcastPartial({ - onDidChangeConnections: Event.None, - onDidChangeSessionResolution: Event.None, - connections: [], - resolveSessionResource: () => ({ connection, connectionAuthority: 'local', backendSession }), - }); - const browserViewService = upcastPartial({ - onDidChangeBrowserViews: Event.None, - getKnownBrowserViews: () => new Map([[browser.id, browser]]), - }); - const visibility = upcastPartial({ - readHiddenKinds: () => new Set([SessionChatPillKind.Browsers]), - isVisible: kind => kind !== SessionChatPillKind.Browsers, - hide: () => { }, - toggle: () => { }, - }); - instantiationService.stub(ISessionChatPillVisibilityService, visibility); - const [clipboardService, configurationService, editorService, openerService] = instantiationService.invokeFunction(accessor => [ - accessor.get(IClipboardService), - accessor.get(IConfigurationService), - accessor.get(IEditorService), - accessor.get(IOpenerService), - ] as const); - - store.add(new AgentHostSessionInputPills( - widget, - false, - connectionsService, - browserViewService, - clipboardService, - configurationService, - editorService, - instantiationService, - openerService, - visibility, - )); - - assert.deepStrictEqual({ - pills: Array.from(persistentContent.querySelectorAll('.chat-pill-label')).map(label => label.textContent), - empty: persistentContent.querySelector('.agent-host-session-input-pills')?.classList.contains('empty'), - }, { - pills: ['1 Artifact'], - empty: false, - }); - }); - - test('hides the session pills in a subagent chat', () => { - const instantiationService = workbenchInstantiationService(undefined, store); - const sessionResource = URI.parse('agent-host-copilot:/session'); - const backendSession = URI.parse('copilot:/session'); - const defaultChat = buildDefaultChatUri(backendSession); - const subagentChat = buildSubagentChatUri(backendSession, 'tool-1'); - const connection = new StaticAgentConnection(new Map([ - [StateComponents.Session, { - defaultChat, - chats: [{ - resource: subagentChat, - origin: { kind: ChatOriginKind.Tool, chat: defaultChat, toolCallId: 'tool-1' }, - }], - _meta: withSessionArtifacts(undefined, [{ - id: 'preview', - type: SessionArtifactType.Website, - label: 'Preview', - link: 'https://example.com/preview', - isArtifact: true, - }]), - } as unknown as SessionState], - ])); - const connectionsService = upcastPartial({ - onDidChangeConnections: Event.None, - onDidChangeSessionResolution: Event.None, - connections: [], - resolveSessionResource: () => ({ connection, connectionAuthority: 'local', backendSession }), - }); - const browserViewService = upcastPartial({ - onDidChangeBrowserViews: Event.None, - getKnownBrowserViews: () => new Map(), - }); - const visibility = upcastPartial({ - readHiddenKinds: () => new Set(), - isVisible: () => true, - hide: () => { }, - toggle: () => { }, - }); - instantiationService.stub(ISessionChatPillVisibilityService, visibility); - const [clipboardService, configurationService, editorService, openerService] = instantiationService.invokeFunction(accessor => [ - accessor.get(IClipboardService), - accessor.get(IConfigurationService), - accessor.get(IEditorService), - accessor.get(IOpenerService), - ] as const); - const persistentContent = document.createElement('div'); - document.body.appendChild(persistentContent); - store.add(toDisposable(() => persistentContent.remove())); - let persistentContentHeight: number | undefined; - // The chat editor keeps one pills instance while its widget navigates - // between the session and one of its subagent chats. - let viewModel = upcastPartial({ sessionResource }); - const viewModelChanged = store.add(new Emitter()); - const widget = upcastPartial({ - inputPart: upcastPartial({ - persistentContentContainerElement: persistentContent, - registerChatPetHorizontalPlatformProvider: () => Disposable.None, - }), - onDidChangeViewModel: viewModelChanged.event, - get viewModel() { return viewModel; }, - setPersistentContentHeight: height => persistentContentHeight = height, - }); - store.add(new AgentHostSessionInputPills( - widget, - false, - connectionsService, - browserViewService, - clipboardService, - configurationService, - editorService, - instantiationService, - openerService, - visibility, - )); - const showChat = (resource: URI) => { - const previousSessionResource = viewModel.sessionResource; - viewModel = upcastPartial({ sessionResource: resource }); - viewModelChanged.fire({ previousSessionResource, currentSessionResource: resource }); - return { - pills: Array.from(persistentContent.querySelectorAll('.chat-pill-label')).map(label => label.textContent), - hidden: persistentContent.querySelector('.agent-host-session-input-pills')?.classList.contains('hidden'), - persistentContentHeight, - }; - }; - const explicitQuery = new URLSearchParams(); - explicitQuery.set(CHAT_SUBAGENT_RESOURCE_QUERY_PARAM, subagentChat); - - assert.deepStrictEqual({ - session: showChat(sessionResource), - // The subagent editor addresses its chat by query parameter, and by - // fragment alone once the session state resolves the chat id. - explicitSubagent: showChat(sessionResource.with({ fragment: 'subagent/tool-1', query: explicitQuery.toString() })), - canonicalSubagent: showChat(sessionResource.with({ fragment: 'subagent/tool-1' })), - backToSession: showChat(sessionResource), - }, { - session: { pills: ['1 Artifact'], hidden: false, persistentContentHeight: 28 }, - explicitSubagent: { pills: [], hidden: true, persistentContentHeight: undefined }, - canonicalSubagent: { pills: [], hidden: true, persistentContentHeight: undefined }, - backToSession: { pills: ['1 Artifact'], hidden: false, persistentContentHeight: 28 }, - }); - }); -});