diff --git a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts index 9f2ce0d2ff4b5..9a4eac6d874e5 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts @@ -112,6 +112,7 @@ export function buildSessionPullRequestSections(pullRequests: readonly IResolved })], ...getChatPillResourceLocation(ref.uri, label), ariaDescription: localize('sessionChatPills.pullRequestDescription', "{0}. {1}", stateDescription, ref.uri.toString(true)), + ...(!pullRequest && ref.title ? { tooltip: `${label}\n${ref.uri.toString(true)}` } : {}), ...(pullRequest ? { pillHover: { element: () => createPullRequestHoverElement({ @@ -142,8 +143,9 @@ interface IResolvedSessionIssue { /** Builds Agents Window issue pill entries, enriching them when live details are available. */ export function buildSessionIssueSections(issues: readonly IResolvedSessionIssue[], session: IActiveSession | undefined, commandService: ICommandService, clipboardService: IClipboardService, openerService: IOpenerService, sessionsService: ISessionsService): readonly IChatPillSection[] { const entries = issues.map(({ ref, issue }) => { - const label = issue?.title - ? localize('sessionChatPills.issueWithTitle', "Issue #{0}: {1}", ref.number, issue.title) + const title = issue?.title ?? ref.title; + const label = title + ? localize('sessionChatPills.issueWithTitle', "Issue #{0}: {1}", ref.number, title) : localize('sessionChatPills.issue', "Issue #{0}", ref.number); return { id: ref.uri.toString(), @@ -157,6 +159,7 @@ export function buildSessionIssueSections(issues: readonly IResolvedSessionIssue run: () => clipboardService.writeText(ref.uri.toString(true)), })], ...getChatPillResourceLocation(ref.uri, label), + ...(!issue && ref.title ? { tooltip: `${label}\n${ref.uri.toString(true)}` } : {}), ...(issue ? { pillHover: { element: () => createIssueHoverElement({ 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 803495795b671..dd327a5c79b36 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts @@ -140,7 +140,13 @@ suite('SessionChatInputToolbar', () => { } test('adds rich GitHub hovers only when live details are available', async () => { - const commandService = upcastPartial({ executeCommand: async () => undefined }); + const commands: { readonly id: string; readonly args: readonly unknown[] }[] = []; + const commandService = upcastPartial({ + executeCommand: async (id, ...args) => { + commands.push({ id, args }); + return undefined; + }, + }); const clipboardService = upcastPartial({ writeText: async () => { } }); const openerService = upcastPartial({ open: async () => true }); const sessionsService = upcastPartial({ setActive: () => { } }); @@ -149,6 +155,7 @@ suite('SessionChatInputToolbar', () => { repo: 'vscode', number: 332982, uri: URI.parse('https://github.com/microsoft/vscode/pull/332982'), + title: 'Recorded pull request title', }; const pullRequest: IGitHubPullRequest = { number: pullRequestRef.number, @@ -171,6 +178,7 @@ suite('SessionChatInputToolbar', () => { repo: 'vscode', number: 42, uri: URI.parse('https://github.com/microsoft/vscode/issues/42'), + title: 'Recorded issue title', }; const issue: IGitHubIssue = { number: issueRef.number, @@ -224,38 +232,67 @@ suite('SessionChatInputToolbar', () => { }; const pullRequestHover = await renderHover(pullRequestEntry); const issueHover = await renderHover(issueEntry); + pullRequestEntry?.open(); + unresolvedIssueEntry?.open(); assert.deepStrictEqual({ pullRequest: { + label: pullRequestEntry?.label, className: pullRequestHover?.className, repository: pullRequestHover?.querySelector('.sessions-pr-hover-repository')?.textContent, title: pullRequestHover?.querySelector('.sessions-pr-hover-title')?.textContent, description: pullRequestHover?.querySelector('.sessions-pr-hover-description-content')?.textContent, branches: [...pullRequestHover?.querySelectorAll('.sessions-pr-hover-branch') ?? []].map(element => element.textContent), + unresolvedLabel: unresolvedPullRequestEntry?.label, + unresolvedAriaLabel: unresolvedPullRequestEntry?.ariaLabel, + unresolvedTooltip: unresolvedPullRequestEntry?.tooltip, unresolvedHover: unresolvedPullRequestEntry?.pillHover, }, issue: { + label: issueEntry?.label, className: issueHover?.className, repository: issueHover?.querySelector('.sessions-issue-hover-repository')?.textContent, title: issueHover?.querySelector('.sessions-issue-hover-title')?.textContent, description: issueHover?.querySelector('.sessions-issue-hover-description-content')?.textContent, + unresolvedLabel: unresolvedIssueEntry?.label, + unresolvedAriaLabel: unresolvedIssueEntry?.ariaLabel, + unresolvedTooltip: unresolvedIssueEntry?.tooltip, unresolvedHover: unresolvedIssueEntry?.pillHover, + openCommands: commands, }, }, { pullRequest: { + label: 'Pull Request #332982: Restore rich pill hovers', className: 'sessions-pr-hover', repository: 'microsoft/vscode', title: 'Restore rich pill hovers', description: 'Provides detailed pull request context.', branches: ['main', 'feature/rich-hover'], + unresolvedLabel: 'Pull Request #332982: Recorded pull request title', + unresolvedAriaLabel: 'Open Pull Request #332982: Recorded pull request title', + unresolvedTooltip: 'Pull Request #332982: Recorded pull request title\nhttps://github.com/microsoft/vscode/pull/332982', unresolvedHover: undefined, }, issue: { + label: 'Issue #42: Rich issue hover', className: 'sessions-issue-hover', repository: 'microsoft/vscode#42', title: 'Rich issue hover', description: 'Provides detailed issue context.', + unresolvedLabel: 'Issue #42: Recorded issue title', + unresolvedAriaLabel: 'Open Issue #42: Recorded issue title', + unresolvedTooltip: 'Issue #42: Recorded issue title\nhttps://github.com/microsoft/vscode/issues/42', unresolvedHover: undefined, + openCommands: [ + { + id: 'workbench.agentSessions.action.openPullRequest', + args: [{ pullRequest: pullRequestRef }], + }, + { + id: 'workbench.agentSessions.action.openIssue', + args: [{ issue: issueRef }], + }, + ], }, }); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionArtifacts.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionArtifacts.ts index a8e420e2a6f9d..518445cdab4ce 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionArtifacts.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionArtifacts.ts @@ -69,6 +69,8 @@ export interface ISessionArtifactPartition { readonly pullRequestTitles: ReadonlyMap; /** Issues this session produced, most recent first. */ readonly issueUrls: readonly string[]; + /** Titles the agent recorded for its issue artifacts, keyed by {@link linkKey}. */ + readonly issueTitles: ReadonlyMap; } interface ISessionArtifactEntry { @@ -98,6 +100,7 @@ export function partitionSessionArtifacts(meta: SessionMeta | undefined): ISessi const pullRequestUrls: string[] = []; const pullRequestTitles = new Map(); const issueUrls: string[] = []; + const issueTitles = new Map(); for (const artifact of readSessionArtifacts(meta)) { const mapped = toSessionArtifact(artifact); @@ -110,17 +113,17 @@ export function partitionSessionArtifacts(meta: SessionMeta | undefined): ISessi continue; } + const titles = artifact.type === SessionArtifactType.Issue ? issueTitles : pullRequestTitles; + const key = linkKey(link); + if (mapped.label && !titles.has(key)) { + titles.set(key, mapped.label); + } + if (artifact.type === SessionArtifactType.Issue) { issueUrls.push(link); continue; } - // The label an agent records for a pull request is its title; keep the - // first one so a later duplicate cannot rewrite it. - const key = linkKey(link); - if (mapped.label && !pullRequestTitles.has(key)) { - pullRequestTitles.set(key, mapped.label); - } pullRequestUrls.push(link); } @@ -129,7 +132,7 @@ export function partitionSessionArtifacts(meta: SessionMeta | undefined): ISessi pullRequestUrls.reverse(); issueUrls.reverse(); - return { entries, pullRequestUrls, pullRequestTitles, issueUrls }; + return { entries, pullRequestUrls, pullRequestTitles, issueUrls, issueTitles }; } /** Case-insensitive de-duplication that keeps the first occurrence's casing. */ diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 8975fd0a422f2..4f4d2823b5bba 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -336,7 +336,12 @@ function isGitHubInfoEqual(a: IGitHubInfo | undefined, b: IGitHubInfo | undefine a.pullRequest?.title === b.pullRequest?.title && a.pullRequest?.baseRefOid === b.pullRequest?.baseRefOid && a.pullRequest?.headRefOid === b.pullRequest?.headRefOid && - arrayEquals(a.issues ?? [], b.issues ?? [], (x, y) => x.owner === y.owner && x.repo === y.repo && x.number === y.number); + arrayEquals(a.issues ?? [], b.issues ?? [], (x, y) => + x.owner === y.owner && + x.repo === y.repo && + x.number === y.number && + isEqual(x.uri, y.uri) && + x.title === y.title); } function dateEquals(a: Date | undefined, b: Date | undefined): boolean { @@ -348,12 +353,17 @@ function markdownStringEquals(a: IMarkdownString | undefined, b: IMarkdownString } /** Maps the GitHub issue URLs recorded on the session's metadata to issue references. */ -function toGitHubIssueRefs(issueUrls: readonly string[] | undefined): readonly IGitHubIssueRef[] | undefined { +function toGitHubIssueRefs(issueUrls: readonly string[] | undefined, titles: ReadonlyMap): readonly IGitHubIssueRef[] | undefined { const refs: IGitHubIssueRef[] = []; for (const url of issueUrls ?? []) { const reference = parseGitHubIssueUrl(url); if (reference) { - refs.push({ ...reference, uri: URI.parse(url) }); + const title = titles.get(linkKey(url)); + refs.push({ + ...reference, + uri: URI.parse(url), + ...(title ? { title } : {}), + }); } } return refs.length > 0 ? refs : undefined; @@ -387,7 +397,7 @@ function toGitHubPullRequestRefs(state: ISessionGitHubState | undefined, pullReq function toGitHubInfo(meta: SessionMeta | undefined): IGitHubInfo | undefined { const state = readSessionGitHubState(meta); const gitState = readSessionGitState(meta); - const { pullRequestUrls, pullRequestTitles, issueUrls } = partitionSessionArtifacts(meta); + const { pullRequestUrls, pullRequestTitles, issueUrls, issueTitles } = partitionSessionArtifacts(meta); // Recorded pull requests lead discovered ones, so the first is the newest. const allPullRequests = toGitHubPullRequestRefs(state, dedupeLinks(pullRequestUrls, getSessionRelatedPullRequestUrls(state)), pullRequestTitles); @@ -408,7 +418,7 @@ function toGitHubInfo(meta: SessionMeta | undefined): IGitHubInfo | undefined { const pullRequests = allPullRequests?.filter(belongsToRepository); const pullRequest = pullRequests?.at(0); - const issues = toGitHubIssueRefs(dedupeLinks(issueUrls))?.filter(belongsToRepository); + const issues = toGitHubIssueRefs(dedupeLinks(issueUrls), issueTitles)?.filter(belongsToRepository); return { owner: repository.owner, diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 6d143ad2b6386..179ee50e1e58d 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -7824,7 +7824,7 @@ suite('LocalAgentHostSessionsProvider', () => { { id: 'a1', type: SessionArtifactType.PullRequest, label: 'Created', isArtifact: true, link: 'https://github.com/owner/repo/pull/50', isGitHub: true }, { id: 'a2', type: SessionArtifactType.PullRequest, label: 'Referenced', isArtifact: false, link: 'https://github.com/owner/repo/pull/60', isGitHub: true }, { id: 'a3', type: SessionArtifactType.PullRequest, label: 'Duplicate', isArtifact: true, link: 'https://github.com/OWNER/REPO/pull/41/', isGitHub: true }, - { id: 'a4', type: SessionArtifactType.Issue, label: 'Issue', isArtifact: true, link: 'https://github.com/owner/repo/issues/7', isGitHub: true }, + { id: 'a4', type: SessionArtifactType.Issue, label: 'Preserve promoted issue titles', isArtifact: true, link: 'https://github.com/owner/repo/issues/7', isGitHub: true }, { id: 'a5', type: SessionArtifactType.PullRequest, label: 'Elsewhere', isArtifact: true, link: 'https://gitlab.com/owner/repo/-/merge_requests/3', isGitHub: false }, { id: 'a6', type: SessionArtifactType.File, label: 'Plan', isArtifact: true, uri: 'file:///repo/plan.md' }, { id: 'a7', type: SessionArtifactType.Issue, label: 'Referenced issue', isArtifact: false, link: 'https://github.com/owner/repo/issues/8', isGitHub: true }, @@ -7842,13 +7842,13 @@ suite('LocalAgentHostSessionsProvider', () => { assert.deepStrictEqual({ activePullRequest: gitHubInfo?.pullRequest?.number, pullRequests: gitHubInfo?.pullRequests?.map(pullRequest => pullRequest.number), - issues: gitHubInfo?.issues?.map(issue => issue.number), + issues: gitHubInfo?.issues?.map(issue => [issue.number, issue.title]), artifacts: session.artifacts?.get().map(artifact => [artifact.id, artifact.isArtifact]), }, { activePullRequest: 41, pullRequests: [41, 50, 42], // Only issues the session produced are polled; a referenced one stays a reference. - issues: [7], + issues: [[7, 'Preserve promoted issue titles']], artifacts: [ ['a8', false], ['a7', false], diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index bb8568f306935..94888f165f9bb 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -404,6 +404,8 @@ export interface IGitHubIssueRef { readonly number: number; /** URI of the issue. */ readonly uri: URI; + /** Issue title recorded by the session, when known. */ + readonly title?: string; } export interface ISessionChangesSummary { @@ -1050,7 +1052,13 @@ export function gitHubInfoEqual(a: IGitHubInfo | undefined, b: IGitHubInfo | und (aIcon === bIcon || (!!aIcon && !!bIcon && ThemeIcon.isEqual(aIcon, bIcon))) && a.pullRequest?.title === b.pullRequest?.title && a.pullRequest?.baseRefOid === b.pullRequest?.baseRefOid && - a.pullRequest?.headRefOid === b.pullRequest?.headRefOid; + a.pullRequest?.headRefOid === b.pullRequest?.headRefOid && + arrayEquals(a.issues ?? [], b.issues ?? [], (x, y) => + x.owner === y.owner && + x.repo === y.repo && + x.number === y.number && + isEqual(x.uri, y.uri) && + x.title === y.title); } /** diff --git a/src/vs/sessions/services/sessions/test/common/session.test.ts b/src/vs/sessions/services/sessions/test/common/session.test.ts index b404e0aafe662..3070ecf9b27fe 100644 --- a/src/vs/sessions/services/sessions/test/common/session.test.ts +++ b/src/vs/sessions/services/sessions/test/common/session.test.ts @@ -198,6 +198,23 @@ suite('sessionWorkspaceEqual', () => { assert.strictEqual(sessionWorkspaceEqual(workspace('main', constObservable(gitHubInfoA)), workspace('main', constObservable(gitHubInfoB))), true); }); + test('compares recorded issue titles in GitHub info', () => { + const uri = URI.parse('https://github.com/owner/repo/issues/42'); + const base: IGitHubInfo = { + owner: 'owner', + repo: 'repo', + issues: [{ owner: 'owner', repo: 'repo', number: 42, uri, title: 'Recorded title' }], + }; + + assert.deepStrictEqual({ + equivalent: sessionWorkspaceEqual(workspace('main', constObservable(base)), workspace('main', constObservable({ ...base, issues: [{ ...base.issues![0] }] }))), + changedTitle: sessionWorkspaceEqual(workspace('main', constObservable(base)), workspace('main', constObservable({ ...base, issues: [{ ...base.issues![0], title: 'Updated title' }] }))), + }, { + equivalent: true, + changedTitle: false, + }); + }); + test('returns false when folder repository metadata changes', () => { assert.strictEqual(sessionWorkspaceEqual(workspace('main'), workspace('feature')), false); }); diff --git a/src/vs/workbench/browser/chatDropdownPill.ts b/src/vs/workbench/browser/chatDropdownPill.ts index 0620e78ae8919..5ef03a663ec55 100644 --- a/src/vs/workbench/browser/chatDropdownPill.ts +++ b/src/vs/workbench/browser/chatDropdownPill.ts @@ -298,7 +298,7 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem { getAriaLabel: item => item.label ?? '', getWidgetAriaLabel: () => this._pillOptions.title, }, - { minWidth: 240, maxWidth: 460, widgetClassName: 'show-file-icons' }, + { minWidth: 240, maxWidth: 460, widgetClassName: 'show-file-icons chat-pill-dropdown' }, ); } diff --git a/src/vs/workbench/browser/media/chatPills.css b/src/vs/workbench/browser/media/chatPills.css index ac98af9b7ff6f..a2a08e3544ff8 100644 --- a/src/vs/workbench/browser/media/chatPills.css +++ b/src/vs/workbench/browser/media/chatPills.css @@ -169,6 +169,14 @@ background-position: center center; } +.action-widget.chat-pill-dropdown .monaco-list .monaco-list-row.has-toolbar:not(.has-standalone-toggle):not(.has-inline-toggle):not(.has-detail) { + padding-right: 0; +} + +.action-widget.chat-pill-dropdown .monaco-list .monaco-list-row.has-toolbar:not(.has-standalone-toggle):not(.has-inline-toggle):not(.has-detail) .action-list-item-toolbar { + margin-right: 0; +} + /* Horizontally scrollable status pills above a chat input. */ .chat-pills-row { width: 100%; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts index f130abce737b6..dc195c4c2f1ac 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionInputPills.ts @@ -67,7 +67,9 @@ const artifactSectionOrder: readonly { readonly type: SessionArtifactType; reado export interface IAgentHostSessionPillMetadata { readonly pullRequestUrls: readonly string[]; + readonly pullRequestTitles: ReadonlyMap; readonly issueUrls: readonly string[]; + readonly issueTitles: ReadonlyMap; readonly artifacts: readonly ISessionArtifact[]; readonly references: readonly ISessionArtifact[]; } @@ -111,16 +113,20 @@ function isPromotedArtifact(artifact: ISessionArtifact, type: SessionArtifactTyp export function getAgentHostSessionPillMetadata(meta: SessionSummaryMeta | undefined): IAgentHostSessionPillMetadata { const entries = readSessionArtifactsNewestFirst(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 artifactPullRequests = entries.filter(entry => isPromotedArtifact(entry, SessionArtifactType.PullRequest)); + const artifactIssues = entries.filter(entry => isPromotedArtifact(entry, SessionArtifactType.Issue)); // Recorded pull requests lead discovered ones, as in the Agents Window. - const pullRequestUrls = dedupeLinks(artifactPullRequests, getSessionRelatedPullRequestUrls(github)); - const issueUrls = dedupeLinks(artifactIssues); + const pullRequestUrls = dedupeLinks(artifactPullRequests.map(entry => entry.link), getSessionRelatedPullRequestUrls(github)); + const pullRequestTitles = new Map(artifactPullRequests.map(entry => [linkKey(entry.link), entry.label])); + const issueUrls = dedupeLinks(artifactIssues.map(entry => entry.link)); + const issueTitles = new Map(artifactIssues.map(entry => [linkKey(entry.link), entry.label])); const promotedLinks = new Set([...pullRequestUrls, ...issueUrls].map(linkKey)); const remaining = entries.filter(entry => !entry.link || !promotedLinks.has(linkKey(entry.link))); return { pullRequestUrls, + pullRequestTitles, issueUrls, + issueTitles, artifacts: remaining.filter(entry => entry.isArtifact), references: remaining.filter(entry => !entry.isArtifact), }; @@ -204,9 +210,19 @@ function parseUri(value: string | undefined): URI | undefined { } } -function referenceLabel(link: string, kind: 'pullRequest' | 'issue'): string { +function referenceLabel(link: string, kind: 'pullRequest' | 'issue', title?: string): string { const resource = parseUri(link); const number = resource ? githubReferenceNumber(resource, kind) : undefined; + if (title) { + if (kind === 'pullRequest') { + return number + ? localize('agentHostSessionPills.pullRequest.numberWithTitle', "Pull Request #{0}: {1}", number, title) + : title; + } + return number + ? localize('agentHostSessionPills.issue.numberWithTitle', "Issue #{0}: {1}", number, title) + : title; + } if (kind === 'pullRequest') { return number ? localize('agentHostSessionPills.pullRequest.number', "Pull Request #{0}", number) @@ -345,12 +361,18 @@ export class AgentHostSessionInputPills extends Disposable { : new Set(); }); - const pullRequestSections = derived(this, reader => this._buildReferenceSections(metadata.read(reader).pullRequestUrls, 'pullRequest', gitHubState.read(reader))); + const pullRequestSections = derived(this, reader => { + const currentMetadata = metadata.read(reader); + return this._buildReferenceSections(currentMetadata.pullRequestUrls, 'pullRequest', gitHubState.read(reader), currentMetadata.pullRequestTitles); + }); 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 issueSections = derived(this, reader => { + const currentMetadata = metadata.read(reader); + return this._buildReferenceSections(currentMetadata.issueUrls, 'issue', undefined, currentMetadata.issueTitles); + }); const artifactSections = derived(this, reader => { const currentResolution = resolution.read(reader); return currentResolution @@ -404,14 +426,15 @@ export class AgentHostSessionInputPills extends Disposable { updateVisibility(inputPills.visible); } - private _buildReferenceSections(links: readonly string[], kind: 'pullRequest' | 'issue', gitHubState?: ReturnType) { + private _buildReferenceSections(links: readonly string[], kind: 'pullRequest' | 'issue', gitHubState?: ReturnType, titles?: ReadonlyMap) { const entries = links.map(link => { const resource = parseUri(link); if (!resource) { return undefined; } const number = githubReferenceNumber(resource, kind); - const label = referenceLabel(link, kind); + const title = titles?.get(linkKey(link)); + const label = referenceLabel(link, kind, title); const pullRequestState = kind === 'pullRequest' && gitHubState?.pullRequestState && gitHubState.pullRequestStateUrl @@ -433,6 +456,7 @@ export class AgentHostSessionInputPills extends Disposable { run: () => this._clipboardService.writeText(resource.toString(true)), })], ...getChatPillResourceLocation(resource, label), + ...(title ? { tooltip: `${label}\n${resource.toString(true)}` } : {}), open: () => this._openExternal(resource), }; }).filter(isDefined); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts index f736cb49080fd..0d284d0b4bc72 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostSessionInputPills.test.ts @@ -12,6 +12,7 @@ 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 { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.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'; @@ -66,6 +67,18 @@ class StaticAgentConnection extends mock() { } } +class TestOpenerService extends mock() { + readonly opened: { readonly resource: URI; readonly options: Parameters[1] }[] = []; + + override async open(resource: URI | string, options?: Parameters[1]): Promise { + this.opened.push({ + resource: typeof resource === 'string' ? URI.parse(resource) : resource, + options, + }); + return true; + } +} + suite('AgentHostSessionInputPills', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -89,7 +102,9 @@ suite('AgentHostSessionInputPills', () => { assert.deepStrictEqual({ pullRequestUrls: metadata.pullRequestUrls, + pullRequestTitles: [...metadata.pullRequestTitles], issueUrls: metadata.issueUrls, + issueTitles: [...metadata.issueTitles], artifactIds: metadata.artifacts.map(artifact => artifact.id), referenceIds: metadata.references.map(reference => reference.id), }, { @@ -97,7 +112,9 @@ suite('AgentHostSessionInputPills', () => { 'https://github.com/microsoft/vscode/pull/2', 'https://github.com/microsoft/vscode/pull/1', ], + pullRequestTitles: [['https://github.com/microsoft/vscode/pull/2', 'Created PR']], issueUrls: ['https://github.com/microsoft/vscode/issues/3'], + issueTitles: [['https://github.com/microsoft/vscode/issues/3', 'Created Issue']], artifactIds: ['website'], // Newest first: `resource` was recorded after `issue-reference`. referenceIds: ['resource', 'issue-reference'], @@ -120,7 +137,9 @@ suite('AgentHostSessionInputPills', () => { assert.deepStrictEqual({ pullRequestUrls: metadata.pullRequestUrls, + pullRequestTitles: [...metadata.pullRequestTitles], issueUrls: metadata.issueUrls, + issueTitles: [...metadata.issueTitles], artifactIds: metadata.artifacts.map(artifact => artifact.id), referenceIds: metadata.references.map(reference => reference.id), }, { @@ -128,15 +147,154 @@ suite('AgentHostSessionInputPills', () => { 'https://github.com/microsoft/vscode/pull/2', 'https://github.com/microsoft/vscode/pull/1', ], + pullRequestTitles: [ + ['https://github.com/microsoft/vscode/pull/2', 'New PR'], + ['https://github.com/microsoft/vscode/pull/1', 'Old PR'], + ], issueUrls: [ 'https://github.com/microsoft/vscode/issues/2', 'https://github.com/microsoft/vscode/issues/1', ], + issueTitles: [ + ['https://github.com/microsoft/vscode/issues/2', 'New Issue'], + ['https://github.com/microsoft/vscode/issues/1', 'Old Issue'], + ], artifactIds: ['new-website', 'old-website'], referenceIds: ['new-reference', 'old-reference'], }); }); + test('renders recorded GitHub titles in editor and panel pills', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const sessionResource = URI.parse('agent-host-copilot:/session'); + const backendSession = URI.parse('copilot:/session'); + const issueUrl = 'https://github.com/microsoft/vscode/issues/335383'; + const firstPullRequestUrl = 'https://github.com/microsoft/vscode/pull/335387'; + const secondPullRequestUrl = 'https://github.com/microsoft/vscode/pull/332982'; + const connection = new StaticAgentConnection(new Map([ + [StateComponents.Session, { + defaultChat: buildDefaultChatUri(backendSession), + chats: [], + _meta: withSessionArtifacts(undefined, [ + { + id: 'issue', + type: SessionArtifactType.Issue, + label: 'Agent Window issue pill discards the recorded issue title', + link: issueUrl, + isGitHub: true, + isArtifact: true, + }, + { + id: 'first-pr', + type: SessionArtifactType.PullRequest, + label: 'sessions: preserve recorded issue titles in pills', + link: firstPullRequestUrl, + isGitHub: true, + isArtifact: true, + }, + { + id: 'second-pr', + type: SessionArtifactType.PullRequest, + label: 'Chat: unify Agent Host status pills across chat surfaces', + link: secondPullRequestUrl, + isGitHub: true, + isArtifact: true, + }, + ]), + } 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: [], + resolveSessionResource: () => ({ connection, connectionAuthority: 'local', backendSession }), + }); + const browserViewService = upcastPartial({ + onDidChangeBrowserViews: Event.None, + getKnownBrowserViews: () => new Map(), + }); + const visibility = store.add(instantiationService.createInstance(SessionChatPillVisibility)); + instantiationService.stub(ISessionChatPillVisibilityService, visibility); + let dropdownLabels: readonly string[] = []; + instantiationService.stub(IActionWidgetService, upcastPartial({ + isVisible: false, + show: (_user, _supportsPreview, items) => { + dropdownLabels = items.map(item => item.label ?? ''); + }, + hide: () => { }, + updateItems: () => { }, + focusItemById: () => { }, + })); + const [clipboardService, configurationService, editorService] = instantiationService.invokeFunction(accessor => [ + accessor.get(IClipboardService), + accessor.get(IConfigurationService), + accessor.get(IEditorService), + ] as const); + const openerService = new TestOpenerService(); + + store.add(new AgentHostSessionInputPills( + widget, + false, + connectionsService, + browserViewService, + clipboardService, + configurationService, + editorService, + instantiationService, + openerService, + visibility, + )); + + const buttons = [...persistentContent.querySelectorAll('.chat-dropdown-pill-button')]; + const [pullRequestButton, issueButton] = buttons; + pullRequestButton?.click(); + issueButton?.click(); + assert.deepStrictEqual({ + pullRequests: { + label: pullRequestButton?.querySelector('.chat-pill-label')?.textContent, + ariaLabel: pullRequestButton?.getAttribute('aria-label'), + dropdownLabels, + }, + issue: { + label: issueButton?.querySelector('.chat-pill-label')?.textContent, + ariaLabel: issueButton?.getAttribute('aria-label'), + ariaDescription: issueButton?.getAttribute('aria-description'), + }, + opened: openerService.opened.map(({ resource, options }) => ({ resource: resource.toString(true), options })), + }, { + pullRequests: { + label: '2 Pull Requests', + ariaLabel: 'Show 2 pull requests', + dropdownLabels: [ + 'Pull Requests', + 'Pull Request #332982: Chat: unify Agent Host status pills across chat surfaces', + 'Pull Request #335387: sessions: preserve recorded issue titles in pills', + ], + }, + issue: { + label: 'Issue #335383: Agent Window issue pill discards the recorded issue title', + ariaLabel: 'Open Issue #335383: Agent Window issue pill discards the recorded issue title', + ariaDescription: issueUrl, + }, + opened: [{ + resource: issueUrl, + options: { openExternal: true, allowContributedOpeners: true, fromUserGesture: true }, + }], + }); + }); + test('resolves the configured session changeset and ignores templated entries', () => { const backendSession = URI.parse('ahp-session:/session'); const changesets: readonly Changeset[] = [ @@ -479,12 +637,12 @@ suite('AgentHostSessionInputPills', () => { const visibility = store.add(instantiationService.createInstance(SessionChatPillVisibility)); const filterActions = createSessionPullRequestPillData(constObservable([]), visibility.pullRequests).getContextMenuActions(); instantiationService.stub(ISessionChatPillVisibilityService, visibility); - const [clipboardService, configurationService, editorService, openerService] = instantiationService.invokeFunction(accessor => [ + const [clipboardService, configurationService, editorService] = instantiationService.invokeFunction(accessor => [ accessor.get(IClipboardService), accessor.get(IConfigurationService), accessor.get(IEditorService), - accessor.get(IOpenerService), ] as const); + const openerService = new TestOpenerService(); store.add(new AgentHostSessionInputPills( widget, @@ -528,12 +686,14 @@ suite('AgentHostSessionInputPills', () => { iconColor: singleIcon?.style.color, hasChevron: singleButton?.querySelector('.chat-pill-chevron') !== null, }; + singleButton?.click(); await filterActions[1].run(); assert.deepStrictEqual({ multiple, filteredLabel, single, + opened: openerService.opened.map(({ resource, options }) => ({ resource: resource.toString(true), options })), filteredOnly: persistentContent.querySelector('.chat-dropdown-pill-button'), canConfigure: persistentContent.querySelector('.chat-pills-row')?.classList.contains('empty'), }, { @@ -552,6 +712,10 @@ suite('AgentHostSessionInputPills', () => { iconColor: 'var(--vscode-charts-purple)', hasChevron: false, }, + opened: [{ + resource: 'https://github.com/microsoft/vscode/pull/1', + options: { openExternal: true, allowContributedOpeners: true, fromUserGesture: true }, + }], filteredOnly: null, canConfigure: true, });