diff --git a/src/vs/base/browser/ui/hover/hover.ts b/src/vs/base/browser/ui/hover/hover.ts index a41b3fc5f0e63c..1dc68ab5f57719 100644 --- a/src/vs/base/browser/ui/hover/hover.ts +++ b/src/vs/base/browser/ui/hover/hover.ts @@ -436,6 +436,8 @@ export function isManagedHoverTooltipMarkdownString(obj: unknown): obj is IManag export interface IManagedHoverTooltipHTMLElement { element: (token: CancellationToken) => HTMLElement | Promise; + /** Whether the returned element owns spacing from the hover boundary. */ + contentOwnsPadding?: boolean; } export function isManagedHoverTooltipHTMLElement(obj: unknown): obj is IManagedHoverTooltipHTMLElement { @@ -446,7 +448,7 @@ export function isManagedHoverTooltipHTMLElement(obj: unknown): obj is IManagedH export type IManagedHoverContent = string | IManagedHoverTooltipMarkdownString | IManagedHoverTooltipHTMLElement | HTMLElement | undefined; export type IManagedHoverContentOrFactory = IManagedHoverContent | (() => IManagedHoverContent); -export interface IManagedHoverOptions extends Pick { +export interface IManagedHoverOptions extends Pick { appearance?: Pick; position?: Pick; } diff --git a/src/vs/base/browser/ui/hover/hoverWidget.css b/src/vs/base/browser/ui/hover/hoverWidget.css index 07151910490748..d14fc7b2c2e0a2 100644 --- a/src/vs/base/browser/ui/hover/hoverWidget.css +++ b/src/vs/base/browser/ui/hover/hoverWidget.css @@ -137,9 +137,13 @@ text-overflow: ellipsis; } +.monaco-hover .hover-row.status-bar .actions .action-container .action { + display: inline-flex; + align-items: center; +} + .monaco-hover .hover-row.status-bar .actions .action-container .action .icon { padding-right: 4px; - vertical-align: middle; font-size: inherit; } diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 48567e36406089..d843b57ff5baa5 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -68,6 +68,15 @@ export interface IActionListItemHover { readonly expandable?: boolean; /** Whether to show the expandable hover's row chevron. Defaults to true. */ readonly showIndicator?: boolean; + /** + * Includes the focused row's toolbar actions and hover-panel controls in one + * Tab sequence while the list retains Up/Down navigation ownership. + */ + readonly tabThroughPanel?: boolean; + /** Interactive elements owned by the hover content, in forward Tab order. */ + readonly getTabbableElements?: () => readonly HTMLElement[]; + /** Whether the hover content supplies its own inset from the panel boundary. */ + readonly contentOwnsPadding?: boolean; /** * CSS class set on the hover panel while this item's hover is showing, so a * consumer can style the panel without reaching for the content inside it. @@ -281,6 +290,7 @@ class ActionItemRenderer implements IListRenderer, IAction private readonly _linkHandler: ((uri: URI, item: IActionListItem) => void) | undefined, private readonly _hideDefaultKeybindingTooltip: boolean, private readonly _registerStandaloneToggle: (item: IActionListItem, toggle: Switch) => IDisposable, + private readonly _registerToolbar: (item: IActionListItem, toolbar: ActionBar) => IDisposable, @IKeybindingService private readonly _keybindingService: IKeybindingService, @IOpenerService private readonly _openerService: IOpenerService, ) { } @@ -507,6 +517,7 @@ class ActionItemRenderer implements IListRenderer, IAction const actionBar = new ActionBar(data.toolbar); data.elementDisposables.add(actionBar); actionBar.push(toolbarActions, { icon: true, label: false }); + data.elementDisposables.add(this._registerToolbar(element, actionBar)); } if (hasSubmenuIndicator(element)) { @@ -782,6 +793,7 @@ export class ActionListWidget extends Disposable { private readonly _filterCts = this._register(new MutableDisposable()); private readonly _groupTitleByIndex = new Map(); private readonly _standaloneToggles = new Map, Switch>(); + private readonly _itemToolbars = new Map, ActionBar>(); private _visibleMenuItems: readonly IActionListItem[]; private readonly _onDidRequestLayout = this._register(new Emitter()); @@ -837,6 +849,13 @@ export class ActionListWidget extends Disposable { // the way back to the row it belongs to lives here. A panel that does have a // submenu list stops these keys before they reach this handler. this._register(dom.addDisposableListener(this._submenuContainer, 'keydown', (e: KeyboardEvent) => { + if ((e.key === 'Enter' || e.key === ' ') && this._currentSubmenuElement?.hover?.tabThroughPanel) { + const target = dom.isHTMLElement(e.target) ? e.target : undefined; + if (target && this._currentSubmenuElement.hover.getTabbableElements?.().includes(target)) { + e.stopPropagation(); + return; + } + } if (e.key !== 'ArrowLeft' && e.key !== 'Escape') { return; } @@ -902,6 +921,13 @@ export class ActionListWidget extends Disposable { this._standaloneToggles.delete(item); } }); + }, (item, toolbar) => { + this._itemToolbars.set(item, toolbar); + return toDisposable(() => { + if (this._itemToolbars.get(item) === toolbar) { + this._itemToolbars.delete(item); + } + }); }, this._keybindingService, this._openerService), new HeaderRenderer(), new SeparatorRenderer(), @@ -1181,7 +1207,9 @@ export class ActionListWidget extends Disposable { const rowElement = this._getRowElement(focused[0]); if (rowElement) { this._showSubmenuForElement(element, rowElement); - if (this._currentSubmenuWidget) { + if (element.hover?.tabThroughPanel) { + this._focusFirstTabThroughPanelControl(element, rowElement); + } else if (this._currentSubmenuWidget) { this._currentSubmenuWidget.focus(); } else { this._submenuContainer.focus(); @@ -1191,6 +1219,7 @@ export class ActionListWidget extends Disposable { } } })); + this._register(dom.addDisposableListener(this.domNode, 'keydown', e => this._handleTabThroughPanelKeyDown(e), true)); if (this._filterInput || this._options?.onType) { this._register(dom.addDisposableListener(this.domNode, 'keydown', (e: KeyboardEvent) => { @@ -1497,6 +1526,7 @@ export class ActionListWidget extends Disposable { } this._list.domFocus(); this._focusCheckedOrFirst(); + this._showTabThroughPanelForFocusedItem(); } clearFocus(): void { @@ -2115,6 +2145,126 @@ export class ActionListWidget extends Disposable { return this.domNode.ownerDocument.getElementById(this._list.getElementID(index)); } + private _showTabThroughPanelForFocusedItem(): void { + const focused = this._list.getFocus(); + if (focused.length === 0) { + return; + } + const index = focused[0]; + const element = this._list.element(index); + if (!element.hover?.tabThroughPanel) { + return; + } + const row = this._getRowElement(index); + if (row) { + this._showSubmenuForElement(element, row); + } + } + + private _getTabThroughPanelControls(element: IActionListItem, row: HTMLElement): { readonly toolbar: ActionBar | undefined; readonly panelControls: readonly HTMLElement[] } { + if (this._currentSubmenuElement !== element) { + this._showSubmenuForElement(element, row); + } + return { + toolbar: this._itemToolbars.get(element), + panelControls: element.hover?.getTabbableElements?.() ?? [], + }; + } + + private _focusFirstTabThroughPanelControl(element: IActionListItem, row: HTMLElement): void { + const controls = this._getTabThroughPanelControls(element, row); + if (controls.toolbar?.length()) { + controls.toolbar.focus(0); + } else { + (controls.panelControls[0] ?? this._list.getHTMLElement()).focus(); + } + } + + private _handleTabThroughPanelKeyDown(event: KeyboardEvent): void { + if (event.isComposing) { + return; + } + const focused = this._list.getFocus(); + if (focused.length === 0) { + return; + } + const index = focused[0]; + const element = this._list.element(index); + if (!element.hover?.tabThroughPanel) { + return; + } + const row = this._getRowElement(index); + const activeElement = dom.getActiveElement(); + if (!row || !dom.isHTMLElement(activeElement)) { + return; + } + const controls = this._getTabThroughPanelControls(element, row); + const inToolbar = controls.toolbar?.isFocused() ?? false; + const inPanel = this._submenuContainer.contains(activeElement); + + if ((event.key === 'ArrowUp' || event.key === 'ArrowDown') && (inToolbar || inPanel)) { + dom.EventHelper.stop(event, true); + this._list.domFocus(); + if (event.key === 'ArrowUp') { + this.focusPrevious(); + } else { + this.focusNext(); + } + return; + } + + if (event.key !== 'Tab') { + return; + } + + let target: HTMLElement | undefined; + if (event.shiftKey) { + if (inPanel) { + if (controls.toolbar?.length()) { + dom.EventHelper.stop(event, true); + controls.toolbar.focus(controls.toolbar.length() - 1); + return; + } + target = this._list.getHTMLElement(); + } else if (controls.toolbar?.isFocused()) { + const toolbarIndex = controls.toolbar.viewItems.findIndex((_, actionIndex) => controls.toolbar?.isFocused(actionIndex)); + if (toolbarIndex > 0) { + dom.EventHelper.stop(event, true); + controls.toolbar.focus(toolbarIndex - 1); + return; + } + target = this._list.getHTMLElement(); + } + } else if (activeElement === this._list.getHTMLElement()) { + if (controls.toolbar?.length()) { + dom.EventHelper.stop(event, true); + controls.toolbar.focus(0); + return; + } + target = controls.panelControls[0]; + } else { + if (controls.toolbar?.isFocused()) { + const toolbarIndex = controls.toolbar.viewItems.findIndex((_, actionIndex) => controls.toolbar?.isFocused(actionIndex)); + if (toolbarIndex + 1 < controls.toolbar.length()) { + dom.EventHelper.stop(event, true); + controls.toolbar.focus(toolbarIndex + 1); + return; + } + target = controls.panelControls[0]; + } else { + const panelControlIndex = controls.panelControls.indexOf(activeElement); + if (panelControlIndex >= 0) { + target = controls.panelControls[panelControlIndex + 1] ?? this._list.getHTMLElement(); + } + } + } + + if (target) { + dom.EventHelper.stop(event, true); + target.focus(); + } + } + private _showHoverForElement(element: IActionListItem, index: number): void { if (this._currentSubmenuElement === element) { return; @@ -2220,6 +2370,7 @@ export class ActionListWidget extends Disposable { hoverHeader = rendered.element; } hoverHeader.classList.add('action-list-submenu-hover-header'); + hoverHeader.classList.toggle('content-owns-padding', element.hover?.contentOwnsPadding === true); if (element.submenuActions?.length) { hoverHeader.classList.add('has-submenu'); } @@ -2410,7 +2561,7 @@ export class ActionListWidget extends Disposable { : edgeRect.left - parentRect.left - panelWidth - gap; this._submenuContainer.style.left = `${left / zoom}px`; - const panelHeight = alignToParent || preserveVerticalPosition ? panelRect.height : totalHeight + (hoverHeader?.offsetHeight ?? 0); + const panelHeight = panelRect.height; if (preserveVerticalPosition) { openingPanelHeight ??= panelHeight / zoom; } diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index 40ae57b7a4bf87..509ee5eda727db 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -578,11 +578,6 @@ } .action-list-submenu-panel { - background-color: var(--vscode-menu-background); - color: var(--vscode-menu-foreground); - border: 1px solid var(--vscode-menu-border, var(--vscode-editorHoverWidget-border)); - border-radius: 5px; - box-shadow: 0 2px 8px var(--vscode-widget-shadow); z-index: 50; width: max-content; /* Hug the inner list so rows (and their hover background) fill the panel. @@ -602,6 +597,10 @@ user-select: text; } +.action-list-submenu-hover-header.content-owns-padding { + padding: 0; +} + .action-list-submenu-hover-header a { color: var(--vscode-textLink-foreground); } diff --git a/src/vs/platform/actionWidget/test/browser/actionList.test.ts b/src/vs/platform/actionWidget/test/browser/actionList.test.ts index f934d57f80b1e6..289e720f8659f5 100644 --- a/src/vs/platform/actionWidget/test/browser/actionList.test.ts +++ b/src/vs/platform/actionWidget/test/browser/actionList.test.ts @@ -1153,6 +1153,129 @@ suite('ActionListWidget', () => { }); }); + test('tabs through a focused row toolbar and hover panel while preserving list navigation', () => { + const createPanel = (id: string) => { + const panel = document.createElement('div'); + const repository = document.createElement('a'); + repository.href = `https://example.com/${id}`; + repository.textContent = `repo-${id}`; + const reference = document.createElement('a'); + reference.href = `https://example.com/${id}/1`; + reference.textContent = `#${id}`; + const branch = document.createElement('button'); + branch.textContent = `branch-${id}`; + branch.setAttribute('aria-label', `Copy branch ${id}`); + panel.append(repository, reference, branch); + return { panel, controls: [repository, reference, branch] }; + }; + const integratedAction = (id: string): IActionListItem => { + let panelControls: readonly HTMLElement[] = []; + return { + ...action(id), + toolbarActions: [toAction({ id: `copy-${id}`, label: `Copy ${id}`, run: () => { } })], + hover: { + content: () => { + const result = createPanel(id); + panelControls = result.controls; + return result.panel; + }, + expandable: true, + showIndicator: false, + tabThroughPanel: true, + getTabbableElements: () => panelControls, + contentOwnsPadding: true, + }, + }; + }; + const widget = createActionListWidget(disposables, { + items: [integratedAction('one'), integratedAction('two')], + listOptions: { showFilter: false, reserveSubmenuSpace: false }, + }); + const press = (key: string, shiftKey = false) => + document.activeElement?.dispatchEvent(new KeyboardEvent('keydown', { key, shiftKey, bubbles: true, cancelable: true })); + const focusState = () => { + const active = document.activeElement; + return { + location: active === widget.domNode.querySelector('.monaco-list') + ? 'list' + : active?.closest('.action-list-submenu-panel') + ? 'panel' + : active?.closest('.action-list-item-toolbar') + ? 'toolbar' + : 'other', + label: active?.getAttribute('aria-label') ?? active?.textContent, + }; + }; + const panel = widget.domNode.querySelector('.action-list-submenu-panel')!; + + widget.focus(); + const initial = { + focus: focusState(), + panelRole: panel.getAttribute('role'), + panelLabel: panel.getAttribute('aria-label'), + contentOwnsPadding: panel.querySelector('.action-list-submenu-hover-header')?.classList.contains('content-owns-padding'), + }; + press('Tab'); + const copy = focusState(); + press('Tab'); + const repository = focusState(); + press('Tab'); + const reference = focusState(); + press('Tab'); + const branch = focusState(); + const bubbledPanelActivationKeys: string[] = []; + widget.domNode.addEventListener('keydown', event => { + if (event.key === 'Enter' || event.key === ' ') { + bubbledPanelActivationKeys.push(event.key); + } + }); + const enterDefaultPreserved = press('Enter'); + const spaceDefaultPreserved = press(' '); + press('Tab', true); + const backToCopy = focusState(); + press('Tab', true); + const backToList = focusState(); + press('Tab'); + press('Tab'); + press('ArrowDown'); + const nextItem = { + focus: focusState(), + item: widget.getFocusedElement()?.item?.id, + panelLabel: panel.getAttribute('aria-label'), + }; + + assert.deepStrictEqual({ + initial, + copy, + repository, + reference, + branch, + panelActivation: { bubbledPanelActivationKeys, enterDefaultPreserved, spaceDefaultPreserved }, + backToCopy, + backToList, + nextItem, + }, { + initial: { + focus: { location: 'list', label: 'Action Widget' }, + panelRole: 'dialog', + panelLabel: 'one', + contentOwnsPadding: true, + }, + copy: { location: 'toolbar', label: 'Copy one' }, + repository: { location: 'panel', label: 'repo-one' }, + reference: { location: 'panel', label: '#one' }, + branch: { location: 'panel', label: 'Copy branch one' }, + panelActivation: { bubbledPanelActivationKeys: [], enterDefaultPreserved: true, spaceDefaultPreserved: true }, + backToCopy: { location: 'toolbar', label: 'Copy one' }, + backToList: { location: 'list', label: 'Action Widget' }, + nextItem: { + focus: { location: 'list', label: 'Action Widget' }, + item: 'two', + panelLabel: 'two', + }, + }); + }); + test('rebuilding the items in place re-measures only when the row count changed', () => { const widget = createActionListWidget(disposables, { items: [action('one'), action('two')] }); const layouts: string[] = []; diff --git a/src/vs/platform/hover/browser/hover.css b/src/vs/platform/hover/browser/hover.css index 26288189fc5354..ecfc2b7515f5d1 100644 --- a/src/vs/platform/hover/browser/hover.css +++ b/src/vs/platform/hover/browser/hover.css @@ -48,6 +48,10 @@ padding: 2px 8px; } +.monaco-hover.workbench-hover.managed-hover-content-owns-padding .hover-contents { + padding: 0; +} + .workbench-hover-container.locked .monaco-hover.workbench-hover { outline: 1px solid var(--vscode-editorHoverWidget-border); } diff --git a/src/vs/platform/hover/browser/updatableHoverWidget.ts b/src/vs/platform/hover/browser/updatableHoverWidget.ts index 627a413c18ce88..6507550354eb65 100644 --- a/src/vs/platform/hover/browser/updatableHoverWidget.ts +++ b/src/vs/platform/hover/browser/updatableHoverWidget.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { isHTMLElement } from '../../../base/browser/dom.js'; -import { isManagedHoverTooltipMarkdownString, type IHoverWidget, type IManagedHoverContent, type IManagedHoverOptions } from '../../../base/browser/ui/hover/hover.js'; +import { isManagedHoverTooltipHTMLElement, isManagedHoverTooltipMarkdownString, type IHoverWidget, type IManagedHoverContent, type IManagedHoverOptions } from '../../../base/browser/ui/hover/hover.js'; import type { IHoverDelegate, IHoverDelegateOptions, IHoverDelegateTarget } from '../../../base/browser/ui/hover/hoverDelegate.js'; import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js'; import { CancellationTokenSource } from '../../../base/common/cancellation.js'; @@ -40,6 +40,7 @@ export class ManagedHoverWidget implements IDisposable { return; } + const contentOwnsPadding = isManagedHoverTooltipHTMLElement(content) && content.contentOwnsPadding === true; let resolvedContent: string | HTMLElement | IMarkdownString | undefined; if (isString(content) || isHTMLElement(content) || content === undefined) { resolvedContent = content; @@ -65,7 +66,7 @@ export class ManagedHoverWidget implements IDisposable { // show 'Loading' if no hover is up yet if (!this._hoverWidget) { - this.show(localize('iconLabel.loading', "Loading..."), focus, options); + this.show(localize('iconLabel.loading', "Loading..."), focus, options, false); } resolvedContent = await managedContent; @@ -80,10 +81,10 @@ export class ManagedHoverWidget implements IDisposable { } } - this.show(resolvedContent, focus, options); + this.show(resolvedContent, focus, options, contentOwnsPadding); } - private show(content: IManagedHoverResolvedContent, focus?: boolean, options?: IManagedHoverOptions): void { + private show(content: IManagedHoverResolvedContent, focus: boolean | undefined, options: IManagedHoverOptions | undefined, contentOwnsPadding: boolean): void { const oldHoverWidget = this._hoverWidget; if (this.hasContent(content)) { @@ -93,6 +94,10 @@ export class ManagedHoverWidget implements IDisposable { actions: options?.actions, linkHandler: options?.linkHandler, trapFocus: options?.trapFocus, + additionalClasses: [ + ...(options?.additionalClasses ?? []), + ...(contentOwnsPadding ? ['managed-hover-content-owns-padding'] : []), + ], appearance: { showPointer: this.hoverDelegate.placement === 'element', skipFadeInAnimation: !this.fadeInAnimation || !!oldHoverWidget, // do not fade in if the hover is already showing diff --git a/src/vs/platform/hover/test/browser/hoverService.test.ts b/src/vs/platform/hover/test/browser/hoverService.test.ts index 7764bb3759eda9..1b6a8925841004 100644 --- a/src/vs/platform/hover/test/browser/hoverService.test.ts +++ b/src/vs/platform/hover/test/browser/hoverService.test.ts @@ -713,6 +713,27 @@ suite('HoverService', () => { ); })); + test('should let managed HTML content own the hover boundary padding', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const target = createTarget(); + const delegate = store.add(instantiationService.createInstance(WorkbenchHoverDelegate, 'element', undefined, {})); + store.add(hoverService.setupManagedHover(delegate, target, { + element: () => mainWindow.document.createElement('div'), + contentOwnsPadding: true, + })); + + target.dispatchEvent(new FocusEvent('focus', { bubbles: true, relatedTarget: document.body })); + await timeout(500); + + const hover = fixture.querySelector('.monaco-hover'); + assert.deepStrictEqual({ + isCompact: hover?.classList.contains('compact'), + contentOwnsPadding: hover?.classList.contains('managed-hover-content-owns-padding'), + }, { + isCompact: true, + contentOwnsPadding: true, + }); + })); + test('should not re-show hover on focus when relatedTarget is from a dismissed hover', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const target = createTarget(); const delegate = store.add(instantiationService.createInstance(WorkbenchHoverDelegate, 'element', undefined, {})); diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index 2a85fdc29f4e6d..02062f092a74ca 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -10,6 +10,7 @@ import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js'; import { renderAsPlaintext } from '../../../../base/browser/markdownRenderer.js'; import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; import { autorun, derived, IObservable, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; @@ -346,6 +347,12 @@ export class ChatView extends AbstractChatView { // Floating status pills above the input. this._chatPills = this._register(instantiationService.createInstance(SessionChatInputToolbar, false, () => this._widget.focusInput())); + this._register(this._widget.inputEditor.onKeyDown(event => { + if (event.keyCode === KeyCode.Tab && event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey && this._chatPills.focusFirst()) { + event.preventDefault(); + event.stopPropagation(); + } + })); const updateChatPillsVisibility = (visible: boolean) => { this._widget.inputPart.persistentContentContainerElement.classList.toggle(chatPersistentContentVisibleClass, visible); }; diff --git a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts index a767c0a28e6183..38279a2ae95e98 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts @@ -43,8 +43,8 @@ import { ISessionChangesStatsCache, readSessionChangesStats } from '../../../ser import { ISessionChangesService } from '../../changes/browser/sessionChangesService.js'; import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; import { getSessionAgentMergeConfigurationObservable } from '../../../browser/sessionAgentMerge.js'; -import { createIssueHoverElement } from '../../github/browser/issueHover.js'; -import { createPullRequestHoverElement } from '../../github/browser/pullRequestHover.js'; +import { createIssueHover } from '../../github/browser/issueHover.js'; +import { createPullRequestHover } from '../../github/browser/pullRequestHover.js'; import { linkKey } from '../../../common/sessionLinks.js'; /** Fake artifacts for the pill debug overlay. */ @@ -72,11 +72,13 @@ function getPullRequestAttention(icon: ThemeIcon, status: IResolvedSessionPullRe return undefined; } -function getGitHubRepositoryHoverData(owner: string, repo: string, openerService: IOpenerService) { +function getGitHubHoverLinkData(owner: string, repo: string, reference: URI, openerService: IOpenerService) { const repository = URI.parse(`https://github.com/${owner}/${repo}`); return { repositoryHref: repository.toString(true), + referenceHref: reference.toString(true), onDidClickRepository: () => { void openerService.open(repository, { openExternal: true }); }, + onDidClickReference: () => { void openerService.open(reference, { openExternal: true }); }, }; } @@ -85,9 +87,26 @@ export function buildSessionPullRequestSections(pullRequests: readonly IResolved const entries = pullRequests.map(({ ref, pullRequest, icon, status }) => { const artifacts = artifactActions?.artifacts.filter(artifact => artifact.isArtifact && artifact.kind === SessionArtifactKind.PullRequest && artifact.link && linkKey(artifact.link.toString(true)) === linkKey(ref.uri.toString(true))); const title = pullRequest?.title ?? ref.title; - const label = title + let hoverTabbableElements: readonly HTMLElement[] = []; + const createHover = pullRequest ? (density: 'default' | 'compact') => createPullRequestHover({ + owner: ref.owner, + repo: ref.repo, + number: ref.number, + ...getGitHubHoverLinkData(ref.owner, ref.repo, ref.uri, openerService), + pullRequest, + density, + ...(pullRequest.baseRef ? { onDidClickBaseBranch: () => { void clipboardService.writeText(pullRequest.baseRef); } } : {}), + ...(pullRequest.headRef ? { onDidClickHeadBranch: () => { void clipboardService.writeText(pullRequest.headRef); } } : {}), + }) : undefined; + const createDropdownHover = createHover ? () => { + const hover = createHover('compact'); + hoverTabbableElements = hover.tabbableElements; + return hover.element; + } : undefined; + const resourceLabel = title ? localize('sessionChatPills.pullRequestWithTitle', "Pull Request #{0}: {1}", ref.number, title) : localize('sessionChatPills.pullRequest', "Pull Request #{0}", ref.number); + const label = title ?? resourceLabel; const resolvedIcon = icon ?? computePullRequestIcon('open'); const attention = getPullRequestAttention(resolvedIcon, status); const pullRequestState = pullRequest?.state ?? ref.liveState ?? ref.state ?? getPullRequestStatusFromIcon(resolvedIcon) ?? 'open'; @@ -106,6 +125,7 @@ export function buildSessionPullRequestSections(pullRequests: readonly IResolved return { id: ref.uri.toString(), label, + ...(title ? { badge: `#${ref.number}`, className: 'chat-pill-github-reference' } : {}), pillLabel: `#${ref.number}`, icon: resolvedIcon, pullRequestState: state, @@ -121,19 +141,12 @@ export function buildSessionPullRequestSections(pullRequests: readonly IResolved class: ThemeIcon.asClassName(Codicon.copy), run: () => clipboardService.writeText(ref.uri.toString(true)), })], - ...getChatPillResourceLocation(ref.uri, label), + ...getChatPillResourceLocation(ref.uri, resourceLabel), 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({ - owner: ref.owner, - repo: ref.repo, - number: ref.number, - ...getGitHubRepositoryHoverData(ref.owner, ref.repo, openerService), - pullRequest, - }), - }, + ...(!pullRequest && ref.title ? { tooltip: `${resourceLabel}\n${ref.uri.toString(true)}` } : {}), + ...(createDropdownHover && createHover ? { + hover: { content: createDropdownHover, expandable: true, showIndicator: false, tabThroughPanel: true, getTabbableElements: () => hoverTabbableElements, contentOwnsPadding: true }, + pillHover: { element: () => createHover('default').element, contentOwnsPadding: true }, } : {}), open: () => { if (session) { @@ -155,12 +168,28 @@ interface IResolvedSessionIssue { 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 title = issue?.title ?? ref.title; - const label = title + let hoverTabbableElements: readonly HTMLElement[] = []; + const createHover = issue ? (density: 'default' | 'compact') => createIssueHover({ + owner: ref.owner, + repo: ref.repo, + number: ref.number, + ...getGitHubHoverLinkData(ref.owner, ref.repo, ref.uri, openerService), + issue, + density, + }) : undefined; + const createDropdownHover = createHover ? () => { + const hover = createHover('compact'); + hoverTabbableElements = hover.tabbableElements; + return hover.element; + } : undefined; + const resourceLabel = title ? localize('sessionChatPills.issueWithTitle', "Issue #{0}: {1}", ref.number, title) : localize('sessionChatPills.issue', "Issue #{0}", ref.number); + const label = title ?? resourceLabel; return { id: ref.uri.toString(), label, + ...(title ? { badge: `#${ref.number}`, className: 'chat-pill-github-reference' } : {}), pillLabel: `#${ref.number}`, icon: issue ? computeIssueIcon(issue.state, issue.stateReason) : computeIssueIcon(GitHubIssueState.Open, undefined), toolbarActions: [toAction({ @@ -169,18 +198,11 @@ export function buildSessionIssueSections(issues: readonly IResolvedSessionIssue class: ThemeIcon.asClassName(Codicon.copy), 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({ - owner: ref.owner, - repo: ref.repo, - number: ref.number, - ...getGitHubRepositoryHoverData(ref.owner, ref.repo, openerService), - issue, - }), - }, + ...getChatPillResourceLocation(ref.uri, resourceLabel), + ...(!issue && ref.title ? { tooltip: `${resourceLabel}\n${ref.uri.toString(true)}` } : {}), + ...(createDropdownHover && createHover ? { + hover: { content: createDropdownHover, expandable: true, showIndicator: false, tabThroughPanel: true, getTabbableElements: () => hoverTabbableElements, contentOwnsPadding: true }, + pillHover: { element: () => createHover('default').element, contentOwnsPadding: true }, } : {}), open: () => { if (session) { @@ -421,6 +443,10 @@ export class SessionChatInputToolbar extends Disposable { return this._inputPills.getPillElements(); } + focusFirst(): boolean { + return this._inputPills.focusFirst(); + } + /** * Track the currently-viewed chat; the toolbar reflects that chat's last-turn * changes and status, resolving the owning session for provider gating and the diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index b690bf3c4b33a2..5ef12e0a1660ab 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -78,7 +78,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.contextReferences', "Type # in the chat input to attach context. Use #file to reference a file or folder, or #session to reference another agent session. Referencing a session together with the /troubleshoot command analyzes that session's logs instead of the current one. Accept a suggestion with Tab or Enter. Openable references appear as buttons above the input; activate one to open it, or use its Remove button to detach it.")); content.push(localize('sessionsChat.pastedText', "Long pasted text is stored as an attached text item and replaced in the input with a numbered inline reference.")); content.push(localize('sessionsChat.pasteAsText', "To paste the clipboard as plain text, without converting it to Markdown or storing it as an attachment, invoke Paste as Text{0}.", '')); - content.push(localize('sessionsChat.backgroundActivities', "Press Shift+Tab from the chat input to reach metadata and status pills above it, then press Enter or Space to activate a pill. Live browsers appear in their own pill, and the chat's subagents of any status appear in another. A pill with more than one entry opens a picker; use the up and down arrows to navigate, Enter to open an entry, and Escape to dismiss the picker and return focus to the pill.")); + content.push(localize('sessionsChat.backgroundActivities', "Press Shift+Tab from the chat input to reach metadata and status pills above it, use the left and right arrows to move between pills, and press Enter or Space to activate one. Live browsers appear in their own pill, and the chat's subagents of any status appear in another. A pill with more than one entry opens a picker. Use the up and down arrows to move between entries. When an entry has details, Tab moves through its row actions and detail links; Shift+Tab returns to the row action, and the up and down arrows continue moving between entries. Press Enter to open an entry, or Escape to dismiss the picker and return focus to the pill.")); content.push(localize('sessionsChat.conversations', "When multiple chats appear as tabs in a single group, the tab row replaces the session header and includes the session actions. Side-by-side chat groups retain the session header and keep their tab rows compact.")); content.push(localize('sessionsChat.sessionsListChats', "Sessions with multiple user-facing chats show those chats nested beneath the session in the Sessions list. Use the arrow keys to navigate the list and Enter to open a chat. Side chats and subagent chats are omitted from this nested list: side chats are reachable from the Side Chats dropdown in the session's overflow menu, and subagent chats open from their pills in the chat transcript.")); content.push(localize('sessionsChat.sessionsListChatContextMenu', "Open a nested chat's context menu to rename it, open it to the side, or, when supported, permanently delete it. Agent Host chats also offer Copy Link.")); 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 185c3488fe0dba..f242dfc4aa65a2 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts @@ -30,7 +30,9 @@ import { ISessionChangesStatsCache } from '../../../../services/sessions/common/ import { BRANCH_CHANGES_CHANGESET_ID, ChatOriginKind, SESSION_CHANGES_CHANGESET_ID, SessionArtifactKind, SessionStatus, type IChat, type IGitHubIssueRef, type IGitHubPullRequestRef, type ISessionArtifact, type ISessionWorkspace } from '../../../../services/sessions/common/session.js'; import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionChangesEditorOptions, ISessionChangesService } from '../../../changes/common/sessionChangesService.js'; -import { GitHubIssueState, GitHubPullRequestState, type IGitHubIssue, type IGitHubPullRequest } from '../../../github/common/types.js'; +import { getGitHubHoverDate, getGitHubHoverDescription, getGitHubHoverTitle, getGitHubHoverTitleParts } from '../../../github/browser/githubHover.js'; +import { createIssueHoverElement } from '../../../github/browser/issueHover.js'; +import { GitHubIssueState, GitHubIssueStateReason, GitHubPullRequestState, type IGitHubIssue, type IGitHubPullRequest } from '../../../github/common/types.js'; import type { IResolvedSessionPullRequest } from '../../../github/browser/pullRequestIconStatus.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; import { GitHubPullRequestModel } from '../../../github/browser/models/githubPullRequestModel.js'; @@ -170,13 +172,14 @@ suite('SessionChatInputToolbar', () => { test('adds rich GitHub hovers only when live details are available', async () => { const commands: { readonly id: string; readonly args: readonly unknown[] }[] = []; + const clipboardWrites: string[] = []; const commandService = upcastPartial({ executeCommand: async (id, ...args) => { commands.push({ id, args }); return undefined; }, }); - const clipboardService = upcastPartial({ writeText: async () => { } }); + const clipboardService = upcastPartial({ writeText: async value => { clipboardWrites.push(value); } }); const openerService = upcastPartial({ open: async () => true }); const sessionsService = upcastPartial({ setActive: () => { } }); const pullRequestRef: IGitHubPullRequestRef = { @@ -190,7 +193,7 @@ suite('SessionChatInputToolbar', () => { number: pullRequestRef.number, title: 'Restore rich pill hovers', body: 'Provides detailed pull request context.', - state: GitHubPullRequestState.Open, + state: GitHubPullRequestState.Merged, author: { login: 'octocat', avatarUrl: '' }, headRef: 'feature/rich-hover', headSha: 'abc123', @@ -198,7 +201,7 @@ suite('SessionChatInputToolbar', () => { isDraft: false, createdAt: '2026-09-03T09:00:00Z', updatedAt: '2026-09-03T10:00:00Z', - mergedAt: undefined, + mergedAt: '2026-09-04T10:00:00Z', mergeable: true, mergeableState: 'clean', }; @@ -213,12 +216,12 @@ suite('SessionChatInputToolbar', () => { number: issueRef.number, title: 'Rich issue hover', body: 'Provides detailed issue context.', - state: GitHubIssueState.Open, - stateReason: undefined, + state: GitHubIssueState.Closed, + stateReason: GitHubIssueStateReason.Completed, author: { login: 'octocat', avatarUrl: '' }, createdAt: '2026-09-03T09:00:00Z', updatedAt: '2026-09-03T10:00:00Z', - closedAt: undefined, + closedAt: '2026-09-04T10:00:00Z', }; const pullRequestEntry = buildSessionPullRequestSections( [{ ref: pullRequestRef, pullRequest, icon: Codicon.gitPullRequest, status: {} }], @@ -244,6 +247,23 @@ suite('SessionChatInputToolbar', () => { openerService, sessionsService, ).flatMap(section => section.entries)[0]; + const activeIssueEntry = buildSessionIssueSections( + [{ + ref: issueRef, + issue: { + ...issue, + state: GitHubIssueState.Open, + stateReason: undefined, + updatedAt: '2026-09-05T10:00:00Z', + closedAt: undefined, + }, + }], + undefined, + commandService, + clipboardService, + openerService, + sessionsService, + ).flatMap(section => section.entries)[0]; const unresolvedIssueEntry = buildSessionIssueSections( [{ ref: issueRef, issue: undefined }], undefined, @@ -259,56 +279,187 @@ suite('SessionChatInputToolbar', () => { } return await entry.pillHover.element(CancellationToken.None); }; + const renderDropdownHover = (entry: IChatPillEntry | undefined) => + typeof entry?.hover?.content === 'function' ? entry.hover.content() : undefined; const pullRequestHover = await renderHover(pullRequestEntry); const issueHover = await renderHover(issueEntry); + const activeIssueHover = await renderHover(activeIssueEntry); + const pullRequestDropdownHover = renderDropdownHover(pullRequestEntry); + const issueDropdownHover = renderDropdownHover(issueEntry); + pullRequestHover?.querySelectorAll('.sessions-pr-hover-branch').forEach(branch => branch.click()); pullRequestEntry?.open(); unresolvedIssueEntry?.open(); assert.deepStrictEqual({ pullRequest: { label: pullRequestEntry?.label, + badge: pullRequestEntry?.badge, + rowClassName: pullRequestEntry?.className, + pillHoverContentOwnsPadding: isManagedHoverTooltipHTMLElement(pullRequestEntry?.pillHover) ? pullRequestEntry.pillHover.contentOwnsPadding : undefined, className: pullRequestHover?.className, + contentOrder: [...pullRequestHover?.children ?? []].map(element => element.className), + provenanceOrder: [...pullRequestHover?.querySelector('.sessions-pr-hover-header')?.children ?? []].map(element => element.className), + titleOrder: [...pullRequestHover?.querySelector('.sessions-pr-hover-title-content')?.childNodes ?? []].map(node => node.nodeType === 3 ? '#text' : (node as HTMLElement).className), + dropdownClassName: pullRequestDropdownHover?.className, + dropdownMatchesStandaloneContent: pullRequestDropdownHover?.textContent === pullRequestHover?.textContent, + dropdownExpandable: pullRequestEntry?.hover?.expandable, + dropdownIndicator: pullRequestEntry?.hover?.showIndicator, + dropdownTabThroughPanel: pullRequestEntry?.hover?.tabThroughPanel, + dropdownTabbableElements: pullRequestEntry?.hover?.getTabbableElements?.().length, + dropdownContentOwnsPadding: pullRequestEntry?.hover?.contentOwnsPadding, repository: pullRequestHover?.querySelector('.sessions-pr-hover-repository')?.textContent, - title: pullRequestHover?.querySelector('.sessions-pr-hover-title')?.textContent, + reference: pullRequestHover?.querySelector('.sessions-pr-hover-reference')?.textContent, + referenceAriaLabel: pullRequestHover?.querySelector('.sessions-pr-hover-reference')?.getAttribute('aria-label'), + status: pullRequestHover?.querySelector('.sessions-pr-hover-status')?.textContent, + statusKind: pullRequestHover?.querySelector('.sessions-pr-hover-status')?.dataset.state, + statusIconAriaHidden: pullRequestHover?.querySelector('.sessions-pr-hover-status .codicon')?.getAttribute('aria-hidden'), + date: pullRequestHover?.querySelector('.sessions-pr-hover-date')?.textContent, + title: pullRequestHover?.querySelector('.sessions-pr-hover-title-content')?.textContent?.replace('#332982', '').trim(), + titleTailOrder: [...pullRequestHover?.querySelector('.sessions-pr-hover-title-tail')?.childNodes ?? []].map(node => node.nodeType === 3 ? '#text' : (node as HTMLElement).className), + titleTooltip: pullRequestHover?.querySelector('.sessions-pr-hover-title')?.getAttribute('title'), description: pullRequestHover?.querySelector('.sessions-pr-hover-description-content')?.textContent, + author: pullRequestHover?.querySelector('.sessions-pr-hover-author')?.textContent, branches: [...pullRequestHover?.querySelectorAll('.sessions-pr-hover-branch') ?? []].map(element => element.textContent), + branchArrowAriaHidden: pullRequestHover?.querySelector('.sessions-pr-hover-branch-arrow')?.getAttribute('aria-hidden'), + branchControls: [...pullRequestHover?.querySelectorAll('.sessions-pr-hover-branch') ?? []].map(element => ({ + tagName: element.tagName, + ariaLabel: element.getAttribute('aria-label'), + })), unresolvedLabel: unresolvedPullRequestEntry?.label, + unresolvedBadge: unresolvedPullRequestEntry?.badge, + unresolvedRowClassName: unresolvedPullRequestEntry?.className, unresolvedAriaLabel: unresolvedPullRequestEntry?.ariaLabel, unresolvedTooltip: unresolvedPullRequestEntry?.tooltip, unresolvedHover: unresolvedPullRequestEntry?.pillHover, + clipboardWrites, }, issue: { label: issueEntry?.label, + badge: issueEntry?.badge, + rowClassName: issueEntry?.className, + pillHoverContentOwnsPadding: isManagedHoverTooltipHTMLElement(issueEntry?.pillHover) ? issueEntry.pillHover.contentOwnsPadding : undefined, className: issueHover?.className, + contentOrder: [...issueHover?.children ?? []].map(element => element.className), + provenanceOrder: [...issueHover?.querySelector('.sessions-issue-hover-header')?.children ?? []].map(element => element.className), + titleOrder: [...issueHover?.querySelector('.sessions-issue-hover-title-content')?.childNodes ?? []].map(node => node.nodeType === 3 ? '#text' : (node as HTMLElement).className), + dropdownClassName: issueDropdownHover?.className, + dropdownMatchesStandaloneContent: issueDropdownHover?.textContent === issueHover?.textContent, + dropdownExpandable: issueEntry?.hover?.expandable, + dropdownIndicator: issueEntry?.hover?.showIndicator, + dropdownTabThroughPanel: issueEntry?.hover?.tabThroughPanel, + dropdownTabbableElements: issueEntry?.hover?.getTabbableElements?.().length, + dropdownContentOwnsPadding: issueEntry?.hover?.contentOwnsPadding, repository: issueHover?.querySelector('.sessions-issue-hover-repository')?.textContent, - title: issueHover?.querySelector('.sessions-issue-hover-title')?.textContent, + reference: issueHover?.querySelector('.sessions-issue-hover-reference')?.textContent, + referenceAriaLabel: issueHover?.querySelector('.sessions-issue-hover-reference')?.getAttribute('aria-label'), + status: issueHover?.querySelector('.sessions-issue-hover-status')?.textContent, + statusKind: issueHover?.querySelector('.sessions-issue-hover-status')?.dataset.state, + statusIconAriaHidden: issueHover?.querySelector('.sessions-issue-hover-status .codicon')?.getAttribute('aria-hidden'), + date: issueHover?.querySelector('.sessions-issue-hover-date')?.textContent, + title: issueHover?.querySelector('.sessions-issue-hover-title-content')?.textContent?.replace('#42', '').trim(), + titleTailOrder: [...issueHover?.querySelector('.sessions-issue-hover-title-tail')?.childNodes ?? []].map(node => node.nodeType === 3 ? '#text' : (node as HTMLElement).className), + titleTooltip: issueHover?.querySelector('.sessions-issue-hover-title')?.getAttribute('title'), description: issueHover?.querySelector('.sessions-issue-hover-description-content')?.textContent, + author: issueHover?.querySelector('.sessions-issue-hover-author')?.textContent, unresolvedLabel: unresolvedIssueEntry?.label, + unresolvedBadge: unresolvedIssueEntry?.badge, + unresolvedRowClassName: unresolvedIssueEntry?.className, unresolvedAriaLabel: unresolvedIssueEntry?.ariaLabel, unresolvedTooltip: unresolvedIssueEntry?.tooltip, unresolvedHover: unresolvedIssueEntry?.pillHover, openCommands: commands, }, + activeIssue: { + status: activeIssueHover?.querySelector('.sessions-issue-hover-status')?.textContent, + date: activeIssueHover?.querySelector('.sessions-issue-hover-date')?.textContent, + }, }, { pullRequest: { - label: 'Pull Request #332982: Restore rich pill hovers', + label: 'Restore rich pill hovers', + badge: '#332982', + rowClassName: 'chat-pill-github-reference', + pillHoverContentOwnsPadding: true, className: 'sessions-pr-hover', + contentOrder: [ + 'sessions-pr-hover-header', + 'sessions-pr-hover-title', + 'sessions-pr-hover-status-row', + 'sessions-pr-hover-description', + 'sessions-pr-hover-branches', + 'sessions-pr-hover-author', + ], + provenanceOrder: ['sessions-pr-hover-repository', 'sessions-pr-hover-date'], + titleOrder: ['#text', 'sessions-pr-hover-title-tail'], + titleTailOrder: ['#text', 'sessions-pr-hover-reference'], + dropdownClassName: 'sessions-pr-hover compact', + dropdownMatchesStandaloneContent: true, + dropdownExpandable: true, + dropdownIndicator: false, + dropdownTabThroughPanel: true, + dropdownTabbableElements: 4, + dropdownContentOwnsPadding: true, repository: 'microsoft/vscode', + reference: '#332982', + referenceAriaLabel: 'Pull Request #332982', + status: 'Merged', + statusKind: 'merged', + statusIconAriaHidden: 'true', + date: 'on Sep 3', title: 'Restore rich pill hovers', + titleTooltip: 'Restore rich pill hovers', description: 'Provides detailed pull request context.', + author: '@octocat opened this pull request', branches: ['main', 'feature/rich-hover'], - unresolvedLabel: 'Pull Request #332982: Recorded pull request title', + branchArrowAriaHidden: 'true', + branchControls: [ + { tagName: 'BUTTON', ariaLabel: 'Copy base branch main' }, + { tagName: 'BUTTON', ariaLabel: 'Copy head branch feature/rich-hover' }, + ], + unresolvedLabel: 'Recorded pull request title', + unresolvedBadge: '#332982', + unresolvedRowClassName: 'chat-pill-github-reference', 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, + clipboardWrites: ['main', 'feature/rich-hover'], }, issue: { - label: 'Issue #42: Rich issue hover', + label: 'Rich issue hover', + badge: '#42', + rowClassName: 'chat-pill-github-reference', + pillHoverContentOwnsPadding: true, className: 'sessions-issue-hover', - repository: 'microsoft/vscode#42', + contentOrder: [ + 'sessions-issue-hover-header', + 'sessions-issue-hover-title', + 'sessions-issue-hover-status-row', + 'sessions-issue-hover-description', + 'sessions-issue-hover-author', + ], + provenanceOrder: ['sessions-issue-hover-repository', 'sessions-issue-hover-date'], + titleOrder: ['#text', 'sessions-issue-hover-title-tail'], + titleTailOrder: ['#text', 'sessions-issue-hover-reference'], + dropdownClassName: 'sessions-issue-hover compact', + dropdownMatchesStandaloneContent: true, + dropdownExpandable: true, + dropdownIndicator: false, + dropdownTabThroughPanel: true, + dropdownTabbableElements: 2, + dropdownContentOwnsPadding: true, + repository: 'microsoft/vscode', + reference: '#42', + referenceAriaLabel: 'Issue #42', + status: 'Closed', + statusKind: 'closed', + statusIconAriaHidden: 'true', + date: 'on Sep 3', title: 'Rich issue hover', + titleTooltip: 'Rich issue hover', description: 'Provides detailed issue context.', - unresolvedLabel: 'Issue #42: Recorded issue title', + author: '@octocat opened this issue', + unresolvedLabel: 'Recorded issue title', + unresolvedBadge: '#42', + unresolvedRowClassName: 'chat-pill-github-reference', unresolvedAriaLabel: 'Open Issue #42: Recorded issue title', unresolvedTooltip: 'Issue #42: Recorded issue title\nhttps://github.com/microsoft/vscode/issues/42', unresolvedHover: undefined, @@ -323,6 +474,105 @@ suite('SessionChatInputToolbar', () => { }, ], }, + activeIssue: { + status: 'Open', + date: 'on Sep 3', + }, + }); + }); + + test('bounds and normalizes GitHub hover descriptions for assistive technology', () => { + const description = getGitHubHoverDescription(`\n## Summary\n\n${'Useful context with [documentation](https://example.com). '.repeat(8)}`, 'No description provided.'); + const unicodeDescription = getGitHubHoverDescription(`${'a'.repeat(198)}😀xy`, 'No description provided.'); + const title = getGitHubHoverTitle(`${'a'.repeat(78)}😀xy`); + const titleParts = getGitHubHoverTitleParts('A title ending in context'); + const singleTokenTitleParts = getGitHubHoverTitleParts('a'.repeat(100)); + + assert.deepStrictEqual({ + startsWithReadableText: description.startsWith('Summary Useful context with documentation.'), + containsMarkdownSyntax: /