From ccd5c25db6369202f01cab466a6ca350f489874b Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Wed, 12 Aug 2026 19:13:19 -0400 Subject: [PATCH 1/9] Unify above the chat input notifications (#330513) * Unify above the chat input notifications * Build notice actions on ActionBar Addresses PR review feedback: the notice actions were a hand-rolled div with button semantics, its own click/tap/keydown listeners and its own hover and focus styling. They are now Action items pushed onto an ActionBar, so keyboard handling, touch, focus management and theming come from the shared action infrastructure rather than being re-implemented here. The producer's class is passed through the action's cssClass so it lands on the button itself - what is clicked, focused and styled - rather than on the housing around it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3c35355f-4d36-46e5-8d7a-cb0bc7207449 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3c35355f-4d36-46e5-8d7a-cb0bc7207449 --- .../lib/stylelint/vscode-known-variables.json | 1 + .../contrib/chat/browser/media/chatInput.css | 38 +-- .../chat/browser/media/newChatInSession.css | 47 +--- .../chat/browser/newChatInSessionWidget.ts | 62 ++-- .../browser/media/voiceModeOnboarding.css | 157 +---------- .../browser/voiceModeOnboarding.ts | 44 +-- .../test/browser/voiceModeOnboarding.test.ts | 4 +- .../speechToText/dictationOnboarding.ts | 40 +-- .../media/dictationOnboarding.css | 164 +---------- .../chatContentParts/chatTipContentPart.ts | 25 +- .../chatContentParts/media/chatTipContent.css | 33 +-- .../widget/input/chatInputNoticeWidget.ts | 245 ++++++++++++++++ .../input/chatInputNotificationWidget.ts | 97 +++---- .../widget/input/chatInputOnboarding.ts | 95 +------ .../widget/input/media/chatInputNotice.css | 264 ++++++++++++++++++ .../media/chatInputNotificationWidget.css | 173 +++--------- .../test/browser/chatInputOnboarding.test.ts | 48 +--- .../chatQuotaNotification.integrationTest.ts | 10 +- .../test/browser/dictationOnboarding.test.ts | 4 +- .../input/chatInputNoticeWidget.test.ts | 222 +++++++++++++++ .../input/chatInputNotificationWidget.test.ts | 14 +- .../chat/chatInputNotice.fixture.ts | 80 ++++++ 22 files changed, 1033 insertions(+), 834 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/chatInputNoticeWidget.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotice.css create mode 100644 src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputNoticeWidget.test.ts create mode 100644 src/vs/workbench/test/browser/componentFixtures/chat/chatInputNotice.fixture.ts diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 2ea421cbe76..314fcd3c210 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -993,6 +993,7 @@ "--prompt-timeline-gutter-dot-size", "--prompt-timeline-gutter-dot-gap", "--prompt-timeline-gutter-more-height", + "--chat-input-notice-severity", "--chat-editing-last-edit-shift", "--chat-voice-icon-glow-color", "--sessions-voice-icon-glow-color", diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInput.css b/src/vs/sessions/contrib/chat/browser/media/chatInput.css index 0267efab21c..b63946ccc6b 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatInput.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatInput.css @@ -663,40 +663,12 @@ /* --- Chat input notification in the new-session homepage --- */ -/* Hide the container when no notification is active */ -.new-chat-input-container > .chat-input-notification-container:not(.has-notification) { - display: none; -} - +/* + * The frame and severity tints come from `.chat-input-notice`, but the overlap does + * not: this surface is a plain block with no row gap, and the workbench offset is + * scoped to `.interactive-input-part`, which is not in this stack. + */ .new-chat-input-container > .chat-input-notification-container.has-notification { /* Overlap the following input-stack surface to avoid a gap between their rounded edges. */ margin-bottom: calc(-1 * var(--vscode-spacing-size80)); } - -.new-chat-input-container > .chat-input-notification-container .chat-input-notification { - padding: 10px 16px 16px 16px; - box-sizing: border-box; - border: 1px solid var(--vscode-input-border, transparent); - border-bottom: none; - border-top-left-radius: 8px; - border-top-right-radius: 8px; - display: flex; - flex-direction: column; - gap: 4px; -} - -/* Severity variants */ -.new-chat-input-container > .chat-input-notification-container .chat-input-notification.severity-info { - border-color: var(--vscode-focusBorder); - background-color: color-mix(in srgb, var(--vscode-focusBorder) 6%, var(--vscode-editorWidget-background)); -} - -.new-chat-input-container > .chat-input-notification-container .chat-input-notification.severity-warning { - border-color: var(--vscode-editorWarning-foreground); - background-color: color-mix(in srgb, var(--vscode-editorWarning-foreground) 6%, var(--vscode-editorWidget-background)); -} - -.new-chat-input-container > .chat-input-notification-container .chat-input-notification.severity-error { - border-color: var(--vscode-editorError-foreground); - background-color: color-mix(in srgb, var(--vscode-editorError-foreground) 6%, var(--vscode-editorWidget-background)); -} diff --git a/src/vs/sessions/contrib/chat/browser/media/newChatInSession.css b/src/vs/sessions/contrib/chat/browser/media/newChatInSession.css index 58c0a8f1adb..2b457e9238f 100644 --- a/src/vs/sessions/contrib/chat/browser/media/newChatInSession.css +++ b/src/vs/sessions/contrib/chat/browser/media/newChatInSession.css @@ -122,23 +122,10 @@ border-radius: 0 !important; } -.new-chat-in-session .sub-session-tip-widget { - display: flex; - align-items: flex-start; - gap: 6px; - width: 100%; - max-width: 100%; - box-sizing: border-box; - padding: 6px 8px; - background-color: var(--vscode-editorWidget-background); - border-radius: var(--vscode-cornerRadius-small) var(--vscode-cornerRadius-small) 0 0; - border: 1px solid var(--vscode-agentsChatInput-border, var(--vscode-editorWidget-border, var(--vscode-input-border, transparent))); - border-bottom: none; - font-size: var(--vscode-chat-font-size-body-s); - font-family: var(--vscode-chat-font-family, inherit); - color: var(--vscode-descriptionForeground); -} - +/* + * The frame, the row layout and the dismiss button all come from + * `.chat-input-notice`; only the two content elements are styled here. + */ .new-chat-in-session .sub-session-tip-icon { flex-shrink: 0; color: var(--vscode-descriptionForeground); @@ -150,29 +137,3 @@ min-width: 0; line-height: 1.4; } - -.new-chat-in-session .sub-session-tip-dismiss { - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - width: 20px; - height: 20px; - border: none; - background: transparent; - padding: 0; - border-radius: var(--vscode-cornerRadius-small); - cursor: pointer; - color: var(--vscode-descriptionForeground); - touch-action: manipulation; -} - -.new-chat-in-session .sub-session-tip-dismiss:hover { - background-color: var(--vscode-toolbar-hoverBackground); - color: var(--vscode-foreground); -} - -.new-chat-in-session .sub-session-tip-dismiss:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; -} diff --git a/src/vs/sessions/contrib/chat/browser/newChatInSessionWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatInSessionWidget.ts index 35f1835654f..575d410f500 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInSessionWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInSessionWidget.ts @@ -9,7 +9,6 @@ import * as dom from '../../../../base/browser/dom.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { constObservable, derived, IObservable } from '../../../../base/common/observable.js'; -import { Gesture, EventType as TouchEventType } from '../../../../base/browser/touch.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -22,6 +21,7 @@ import { NewChatInputWidget } from './newChatInput.js'; import { IChatViewOptions } from '../../../browser/parts/chatView.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { ChatInputNoticeLane } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputNoticeHost.js'; +import { ChatInputNoticeVariant, ChatInputNoticeWidget } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputNoticeWidget.js'; // #region --- New Chat In Session Widget --- @@ -101,34 +101,33 @@ export class NewChatInSessionWidget extends Disposable { return; } + const store = new DisposableStore(); const tipContainer = dom.append(container, dom.$('.sub-session-tip-container')); - const tipWidget = dom.append(tipContainer, dom.$('.sub-session-tip-widget')); - tipWidget.setAttribute('role', 'status'); - tipWidget.setAttribute('aria-label', localize('subSessionTip.ariaLabel', "New chat tip")); - // Reachable by the notice focus command, like every other notice above an input. - tipWidget.tabIndex = 0; - // Tip icon - const iconEl = dom.append(tipWidget, renderIcon(Codicon.lightbulb)); - iconEl.classList.add('sub-session-tip-icon'); - - // Tip text - const textEl = dom.append(tipWidget, dom.$('span.sub-session-tip-text')); - textEl.textContent = localize( + const message = localize( 'subSessionTip.message', "Start a parallel conversation to build on all the changes made in this session." ); - // Dismiss button - const dismissBtn = dom.append(tipWidget, dom.$('button.sub-session-tip-dismiss')) as HTMLButtonElement; - dismissBtn.type = 'button'; - dismissBtn.setAttribute('aria-label', localize('subSessionTip.dismiss', "Dismiss tip")); - dom.append(dismissBtn, renderIcon(Codicon.close)); + // Named by what it says, like every other tip: the label is both what the + // landmark is called and what is spoken when the tip first appears. + const tip = store.add(new ChatInputNoticeWidget({ + container: tipContainer, + variant: ChatInputNoticeVariant.Tip, + ariaLabel: message, + ariaRoleDescription: localize('subSessionTip.ariaLabel', "New chat tip"), + })); + + const iconEl = dom.append(tip.domNode, renderIcon(Codicon.lightbulb)); + iconEl.classList.add('sub-session-tip-icon'); + + const textEl = dom.append(tip.domNode, dom.$('span.sub-session-tip-text')); + textEl.textContent = message; const dismiss = () => { // Removing the banner would strand keyboard focus on , which also // drops the context keys the chat keybindings depend on. - const hadFocus = dom.isAncestorOfActiveElement(tipWidget); + const hadFocus = tip.hasFocus(); this.storageService.store(STORAGE_KEY_SUB_SESSION_TIP_DISMISSED, true, StorageScope.PROFILE, StorageTarget.USER); tipContainer.remove(); this._tipDisposable.clear(); @@ -137,32 +136,35 @@ export class NewChatInSessionWidget extends Disposable { } }; - const handleDismiss = (e: Event) => { - dom.EventHelper.stop(e, true); - dismiss(); - }; - - const store = new DisposableStore(); - store.add(Gesture.addTarget(dismissBtn)); - store.add(dom.addDisposableListener(dismissBtn, dom.EventType.CLICK, handleDismiss)); - store.add(dom.addDisposableListener(dismissBtn, TouchEventType.Tap, handleDismiss)); + tip.addDismissAction({ + ariaLabel: localize('subSessionTip.dismiss', "Dismiss tip"), + onActivate: dismiss, + }); // Claims the tip lane above this input, so the banner yields to a // notification or a first-run introduction instead of stacking with them. // Hidden until the claim leads, which it does immediately when nothing // else holds the space. let leading = false; + let announced = false; dom.setVisibility(false, tipContainer); store.add(this._newChatInput.noticeHost.occupy(ChatInputNoticeLane.Tip, { focusTarget: { - hasFocus: () => dom.isAncestorOfActiveElement(tipWidget), - focus: () => tipWidget.focus(), + hasFocus: () => tip.hasFocus(), + focus: () => tip.focus(), canFocus: () => leading, }, onDidChangeLeading: isLeading => { leading = isLeading; tipContainer.classList.toggle(SHOWING_SUB_SESSION_TIP_CLASS, isLeading); dom.setVisibility(isLeading, tipContainer); + // Spoken once, the first time it actually reaches the screen. The + // lane can hand back and forth as notifications come and go, and + // re-announcing on every return would talk over the user. + if (isLeading && !announced) { + announced = true; + tip.announce(); + } }, })); this._tipDisposable.value = store; diff --git a/src/vs/workbench/contrib/agentsVoice/browser/media/voiceModeOnboarding.css b/src/vs/workbench/contrib/agentsVoice/browser/media/voiceModeOnboarding.css index 80fb9da233f..7f39a34fc35 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/media/voiceModeOnboarding.css +++ b/src/vs/workbench/contrib/agentsVoice/browser/media/voiceModeOnboarding.css @@ -19,30 +19,19 @@ */ /* The host is a peer directly above the chat input, and stays out of the layout - * entirely until the card is attached. */ + * entirely until the card is attached. The frame comes from `.chat-input-notice`; + * the overlap with the input below is per-surface and is set here. */ .voice-mode-onboarding-container { display: none; } .voice-mode-onboarding-container.has-voice-mode-onboarding { display: block; - /* Tuck the squared-off bottom edge behind the rounded top of the chat input - * so the two surfaces read as one stack with no visible seam. */ - margin-bottom: -10px; - padding-bottom: 10px; -} - -.voice-mode-onboarding-banner { - position: relative; - display: flex; - flex-direction: column; - gap: var(--vscode-spacing-size80); - box-sizing: border-box; - padding: var(--vscode-spacing-size120); - border: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-widget-border, transparent)); - border-radius: var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-xSmall) var(--vscode-cornerRadius-xSmall); - background-color: var(--vscode-agentsChatInput-background, var(--vscode-input-background)); - color: var(--vscode-foreground); + /* Pull the input up over the card's squared bottom edge so the two read + * as one stack with no visible seam. Kept next to the rule it overrides: + * split across files these two tie on specificity. */ + margin-bottom: calc(-1 * var(--vscode-spacing-size100)); + padding-bottom: var(--vscode-spacing-size100); } /* --- Copy --- */ @@ -56,39 +45,6 @@ padding-right: var(--vscode-spacing-size240); } -.voice-mode-onboarding-title { - font-size: var(--vscode-fontSize-label1); - font-weight: var(--vscode-fontWeight-semiBold); -} - -.voice-mode-onboarding-description { - font-size: var(--vscode-fontSize-label2); - color: var(--vscode-descriptionForeground); - /* Wraps rather than truncating: this sentence carries the promise, the ask - * and the escape hatch, so it survives at every width. */ - line-height: 1.4; -} - -/* The settings link sits inside the sentence, so it takes its colour from the - * link token and nothing else - no weight, no size, no box that would lift it - * out of the prose it belongs to. */ -.voice-mode-onboarding-description a { - color: var(--vscode-textLink-foreground); - cursor: pointer; - border-radius: var(--vscode-cornerRadius-xSmall); -} - -.voice-mode-onboarding-description a:hover, -.voice-mode-onboarding-description a:active { - color: var(--vscode-textLink-activeForeground); - text-decoration: underline; -} - -.voice-mode-onboarding-description a:focus-visible { - outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); - outline-offset: 2px; -} - /* --- Waveform --- */ /* @@ -118,37 +74,6 @@ color: var(--vscode-descriptionForeground); } -/* --- Microphone picker --- */ - -.voice-mode-onboarding-microphone-picker { - position: relative; - display: flex; - align-items: center; - gap: var(--vscode-spacing-size60); - box-sizing: border-box; - min-width: 0; - height: var(--vscode-spacing-size280); - padding: 0 var(--vscode-spacing-size80); - border: var(--vscode-strokeThickness) solid var(--vscode-dropdown-border, var(--vscode-input-border, transparent)); - border-radius: var(--vscode-cornerRadius-small); - background-color: var(--vscode-dropdown-background, var(--vscode-input-background)); - transition: border-color 100ms ease-out; -} - -.voice-mode-onboarding-microphone-picker[hidden] { - display: none; -} - -.voice-mode-onboarding-microphone-picker:hover { - border-color: var(--vscode-focusBorder); -} - -.voice-mode-onboarding-microphone-picker:focus-within { - border-color: var(--vscode-focusBorder); - outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); - outline-offset: -2px; -} - .monaco-workbench .voice-mode-onboarding-microphone-icon.codicon[class*='codicon-'] { flex-shrink: 0; font-size: var(--vscode-codiconFontSize-compact); @@ -156,24 +81,6 @@ color: var(--vscode-descriptionForeground); } -.voice-mode-onboarding-microphone-picker .monaco-select-box { - flex: 1 1 auto; - min-width: 0; - height: 100%; - padding: 0; - border: none; - border-radius: var(--vscode-cornerRadius-small); - background-color: transparent; - color: var(--vscode-dropdown-foreground, var(--vscode-foreground)); - font-family: inherit; - font-size: var(--vscode-fontSize-label2); - text-overflow: ellipsis; -} - -.voice-mode-onboarding-microphone-picker .monaco-select-box:focus { - outline: none; -} - /* --- Voices and confirmation --- */ .voice-mode-onboarding-actions { @@ -260,7 +167,7 @@ justify-content: center; flex-shrink: 0; width: 0; - height: var(--vscode-codiconFontSize-compact, 12px); + height: var(--vscode-codiconFontSize-compact); overflow: hidden; transition: width 0.2s cubic-bezier(0.2, 0.9, 0.2, 1), margin-right 0.2s cubic-bezier(0.2, 0.9, 0.2, 1); } @@ -269,7 +176,7 @@ .voice-mode-onboarding-voice:focus-visible .voice-mode-onboarding-voice-icon, .voice-mode-onboarding-voice.selected .voice-mode-onboarding-voice-icon, .voice-mode-onboarding-voice.playing .voice-mode-onboarding-voice-icon { - width: var(--vscode-codiconFontSize-compact, 12px); + width: var(--vscode-codiconFontSize-compact); margin-right: var(--vscode-spacing-size20); } @@ -283,7 +190,7 @@ } .monaco-workbench .voice-mode-onboarding-voice-icon .codicon[class*='codicon-'] { - font-size: var(--vscode-codiconFontSize-compact, 12px); + font-size: var(--vscode-codiconFontSize-compact); line-height: 1; color: inherit; } @@ -318,7 +225,7 @@ .voice-mode-onboarding-voice-bars { gap: 1px; - height: var(--vscode-codiconFontSize-compact, 12px); + height: var(--vscode-codiconFontSize-compact); } .voice-mode-onboarding-voice-bar { @@ -359,45 +266,3 @@ .monaco-reduce-motion .voice-mode-onboarding-voice-icon > * { transition: none; } - -/* --- Close --- */ - -/* - * Pinned to the corner and out of the content flow, so it never competes with - * the voices for room and never moves as the card re-tiers. Always available: - * a disabled dismiss would trap someone inside the card. - */ -.voice-mode-onboarding-close { - position: absolute; - top: var(--vscode-spacing-size80); - right: var(--vscode-spacing-size80); - display: flex; - align-items: center; - justify-content: center; - width: var(--vscode-spacing-size200); - height: var(--vscode-spacing-size200); - border-radius: var(--vscode-cornerRadius-small); - color: var(--vscode-descriptionForeground); - cursor: pointer; -} - -.voice-mode-onboarding-close:hover { - background-color: var(--vscode-toolbar-hoverBackground); - color: var(--vscode-foreground); -} - -.voice-mode-onboarding-close:focus-visible { - outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); - outline-offset: -1px; -} - -/* - * `.monaco-workbench .codicon` sets colour and size directly on the glyph, so - * without out-ranking it this renders as a dark 16px icon rather than a compact - * one inheriting the button's foreground. - */ -.monaco-workbench .voice-mode-onboarding-close .codicon[class*='codicon-'] { - font-size: var(--vscode-codiconFontSize-compact, 12px); - line-height: 1; - color: inherit; -} diff --git a/src/vs/workbench/contrib/agentsVoice/browser/voiceModeOnboarding.ts b/src/vs/workbench/contrib/agentsVoice/browser/voiceModeOnboarding.ts index 507dbd06966..6e8459d32d4 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/voiceModeOnboarding.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/voiceModeOnboarding.ts @@ -24,7 +24,8 @@ import { IContextViewService } from '../../../../platform/contextview/browser/co import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { CONFIGURE_VOICE_INSTRUCTIONS_ACTION_ID } from '../../chat/browser/actions/configureVoiceInstructionsAction.js'; -import { ChatInputOnboarding, ChatInputOnboardingCard, IChatInputOnboardingBanner, IChatInputOnboardingContext, IChatInputOnboardingHostOptions } from '../../chat/browser/widget/input/chatInputOnboarding.js'; +import { ChatInputOnboarding, IChatInputOnboardingBanner, IChatInputOnboardingContext, IChatInputOnboardingHostOptions } from '../../chat/browser/widget/input/chatInputOnboarding.js'; +import { ChatInputNoticeVariant, ChatInputNoticeWidget } from '../../chat/browser/widget/input/chatInputNoticeWidget.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { defaultSelectBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { asCssVariable, asCssVariableWithDefault, selectBackground, selectListBackground } from '../../../../platform/theme/common/colorRegistry.js'; @@ -675,11 +676,8 @@ interface IVoiceElement { * afterwards. The leading icon carries that story: play before the click, * animating bars while it speaks, then a check once it is yours. */ -export class VoiceModeOnboardingBanner extends Disposable implements IChatInputOnboardingBanner { +export class VoiceModeOnboardingBanner extends ChatInputNoticeWidget implements IChatInputOnboardingBanner { - readonly domNode: HTMLElement; - - private readonly card: ChatInputOnboardingCard; private readonly player: VoiceSamplePlayer; private animator: VoiceModeOnboardingAnimator | undefined; private readonly options: IVoiceModeOnboardingBannerOptions; @@ -711,12 +709,9 @@ export class VoiceModeOnboardingBanner extends Disposable implements IChatInputO @IStorageService private readonly storageService: IStorageService, @ITelemetryService private readonly telemetryService: ITelemetryService, ) { - super(); - - this.options = options; - - this.card = this._register(new ChatInputOnboardingCard({ + super({ container: options.container, + variant: ChatInputNoticeVariant.Onboarding, className: 'voice-mode-onboarding-banner', ariaLabel: localize('voiceMode.onboarding.region', "Voice Mode introduction"), ariaDescription: localize('voiceMode.onboarding.regionDescription', "Choose how your agent speaks to you. Adjust settings anytime."), @@ -724,14 +719,15 @@ export class VoiceModeOnboardingBanner extends Disposable implements IChatInputO this.logAction('escape'); this.options.onDismiss(); }, - })); - this.domNode = this.card.domNode; + }); + + this.options = options; this.localizedVoice = localizedVoiceForLanguage(this.resolveSpokenLanguage()); this.player = this._register(instantiationService.createInstance(VoiceSamplePlayer, this.domNode, options.audioFactory)); this._register(this.player.onDidChangePlayingVoice(voiceId => this.updatePlaying(voiceId))); const copy = dom.append(this.domNode, dom.$('.voice-mode-onboarding-copy')); - const title = dom.append(copy, dom.$('.voice-mode-onboarding-title')); + const title = dom.append(copy, dom.$('.chat-input-notice-title.voice-mode-onboarding-title')); title.textContent = localize('voiceMode.onboarding.title', "Welcome to Voice Mode"); this.renderDescription(copy); @@ -774,7 +770,7 @@ export class VoiceModeOnboardingBanner extends Disposable implements IChatInputO } private renderMicrophonePicker(): void { - this.microphonePickerContainer = dom.append(this.domNode, dom.$('.voice-mode-onboarding-microphone-picker')); + this.microphonePickerContainer = dom.append(this.domNode, dom.$('.chat-input-notice-picker.voice-mode-onboarding-microphone-picker')); this.microphoneOptions = [{ deviceId: '', label: localize('voiceMode.onboarding.systemDefault', "System default"), @@ -1018,7 +1014,7 @@ export class VoiceModeOnboardingBanner extends Disposable implements IChatInputO * concatenated onto the end. */ private renderDescription(container: HTMLElement): void { - const description = dom.append(container, dom.$('.voice-mode-onboarding-description')); + const description = dom.append(container, dom.$('.chat-input-notice-description.voice-mode-onboarding-description')); const text = localize({ key: 'voiceMode.onboarding.description', comment: [ @@ -1062,38 +1058,26 @@ export class VoiceModeOnboardingBanner extends Disposable implements IChatInputO * ever "I am done here" - and closing is what hands the session back. */ private renderClose(): void { - this.card.addAction({ + this.addDismissAction({ className: 'voice-mode-onboarding-close', ariaLabel: localize('voiceMode.onboarding.close', "Close the introduction"), - icon: Codicon.closeCompact, onActivate: () => this.finish(), }); } - announce(): void { - this.card.announce(); - } - /** * Stops the sample and the waveform while the card is put away for a * notification, so an invisible introduction is not still playing audio or * painting every frame. */ - setVisible(visible: boolean): void { + override setVisible(visible: boolean): void { + super.setVisible(visible); this.animator?.setSuspended(!visible); if (!visible) { this.player.stop(); } } - hasFocus(): boolean { - return this.card.hasFocus(); - } - - focus(): void { - this.card.focus(); - } - private selectVoice(voice: IVoiceModeVoice): void { if (this.player.playingVoice === voice.id) { this.player.stop(); diff --git a/src/vs/workbench/contrib/agentsVoice/test/browser/voiceModeOnboarding.test.ts b/src/vs/workbench/contrib/agentsVoice/test/browser/voiceModeOnboarding.test.ts index f1729444599..5cb1776f0f2 100644 --- a/src/vs/workbench/contrib/agentsVoice/test/browser/voiceModeOnboarding.test.ts +++ b/src/vs/workbench/contrib/agentsVoice/test/browser/voiceModeOnboarding.test.ts @@ -317,14 +317,14 @@ suite('Voice Mode onboarding', () => { activeElement: document.activeElement, card, tabIndex: card?.tabIndex, - closeIcon: host.container.querySelector('.voice-mode-onboarding-close .codicon')?.className, + closeIcon: host.container.querySelector('.voice-mode-onboarding-close')?.className, listeningNotice: host.container.querySelector('.voice-mode-onboarding-listening-notice'), }, { activeElement: document.body, card, tabIndex: 0, - closeIcon: 'codicon codicon-close-compact', + closeIcon: 'action-label codicon codicon-close-compact voice-mode-onboarding-close chat-input-notice-dismiss', listeningNotice: null, }); }); diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/dictationOnboarding.ts b/src/vs/workbench/contrib/chat/browser/speechToText/dictationOnboarding.ts index 7c3c6edca37..be517665ce4 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/dictationOnboarding.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/dictationOnboarding.ts @@ -24,7 +24,8 @@ import { ITelemetryService } from '../../../../../platform/telemetry/common/tele import { defaultSelectBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { AgentsVoiceStorageKeys } from '../../../agentsVoice/common/agentsVoice.js'; import { CONFIGURE_DICTATION_INSTRUCTIONS_ACTION_ID } from '../actions/configureVoiceInstructionsAction.js'; -import { ChatInputOnboarding, ChatInputOnboardingCard, IChatInputOnboardingBanner, IChatInputOnboardingHostOptions } from '../widget/input/chatInputOnboarding.js'; +import { ChatInputOnboarding, IChatInputOnboardingBanner, IChatInputOnboardingHostOptions } from '../widget/input/chatInputOnboarding.js'; +import { ChatInputNoticeVariant, ChatInputNoticeWidget } from '../widget/input/chatInputNoticeWidget.js'; import './media/dictationOnboarding.css'; /** @@ -543,11 +544,8 @@ export interface IDictationOnboardingBannerOptions { * The card runs alongside the first dictation, so it explains the feature * without delaying the action the user invoked. */ -export class DictationOnboardingBanner extends Disposable implements IChatInputOnboardingBanner { +export class DictationOnboardingBanner extends ChatInputNoticeWidget implements IChatInputOnboardingBanner { - readonly domNode: HTMLElement; - - private readonly card: ChatInputOnboardingCard; private readonly preview: MicrophonePreview | undefined; private readonly waveform: MicrophoneWaveform; private readonly hint: HTMLElement | undefined; @@ -569,28 +567,26 @@ export class DictationOnboardingBanner extends Disposable implements IChatInputO @IStorageService private readonly storageService: IStorageService, @ITelemetryService private readonly telemetryService: ITelemetryService, ) { - super(); - - this.card = this._register(new ChatInputOnboardingCard({ + super({ container: bannerOptions.container, + variant: ChatInputNoticeVariant.Onboarding, className: 'dictation-onboarding-banner', ariaLabel: localize('dictation.onboarding.region', "Dictation introduction"), ariaDescription: bannerOptions.previewMicrophone ? localize('dictation.onboarding.regionDescription.preview', "Say anything to check your microphone.") : localize('dictation.onboarding.regionDescription', "Speak and it becomes text."), onEscape: () => this.dismiss('escape'), - })); - this.domNode = this.card.domNode; + }); const header = dom.append(this.domNode, dom.$('.dictation-onboarding-header')); - const title = dom.append(header, dom.$('.dictation-onboarding-title')); + const title = dom.append(header, dom.$('.chat-input-notice-title.dictation-onboarding-title')); title.textContent = localize('dictation.onboarding.title', "Dictation"); this.renderDescription(header); this.renderClose(); const device = dom.append(this.domNode, dom.$('.dictation-onboarding-device')); - this.pickerContainer = dom.append(device, dom.$('.dictation-onboarding-picker')); + this.pickerContainer = dom.append(device, dom.$('.chat-input-notice-picker.dictation-onboarding-picker')); this.options = [{ deviceId: SYSTEM_DEFAULT_DEVICE_ID, label: localize('dictation.onboarding.systemDefault', "System default"), @@ -629,16 +625,13 @@ export class DictationOnboardingBanner extends Disposable implements IChatInputO this.logAction('shown'); } - announce(): void { - this.card.announce(); - } - /** * Stops the waveform and releases the microphone while the card is put away * for a notification, so an invisible introduction never holds the microphone * open or keeps painting. */ - setVisible(visible: boolean): void { + override setVisible(visible: boolean): void { + super.setVisible(visible); if (visible) { this.waveform.start(); if (this.preview) { @@ -650,14 +643,6 @@ export class DictationOnboardingBanner extends Disposable implements IChatInputO } } - hasFocus(): boolean { - return this.card.hasFocus(); - } - - focus(): void { - this.card.focus(); - } - /** * What dictation is, and that none of it is fixed. The card is shown once, so * the two things a user might want to change afterwards - whether dictation @@ -668,7 +653,7 @@ export class DictationOnboardingBanner extends Disposable implements IChatInputO * sentence natural instead of having fixed phrases concatenated on. */ private renderDescription(container: HTMLElement): void { - const description = dom.append(container, dom.$('.dictation-onboarding-description')); + const description = dom.append(container, dom.$('.chat-input-notice-description.dictation-onboarding-description')); const text = localize({ key: 'dictation.onboarding.description', comment: ['Preserve the double square brackets: they mark the text that becomes a link. Keep both links, in this order - the first opens settings, the second opens the customization file.'], @@ -841,10 +826,9 @@ export class DictationOnboardingBanner extends Disposable implements IChatInputO } private renderClose(): void { - this.card.addAction({ + this.addDismissAction({ className: 'dictation-onboarding-close', ariaLabel: localize('dictation.onboarding.close', "Close the introduction"), - icon: Codicon.close, onActivate: () => this.dismiss('close'), }); } diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/media/dictationOnboarding.css b/src/vs/workbench/contrib/chat/browser/speechToText/media/dictationOnboarding.css index 0679901e8dd..8ef8e1105c6 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/media/dictationOnboarding.css +++ b/src/vs/workbench/contrib/chat/browser/speechToText/media/dictationOnboarding.css @@ -21,30 +21,26 @@ */ /* The host is a peer directly above the chat input, and stays out of the layout - * entirely until the card is attached. */ + * entirely until the card is attached. The frame comes from `.chat-input-notice`; + * the overlap with the input below is per-surface and is set here. */ .dictation-onboarding-container { display: none; } .dictation-onboarding-container.has-dictation-onboarding { display: block; - /* Tuck the squared-off bottom edge behind the rounded top of the chat input - * so the two surfaces read as one stack with no visible seam. */ - margin-bottom: -10px; - padding-bottom: 10px; + /* Pull the input up over the card's squared bottom edge so the two read + * as one stack with no visible seam. Kept next to the rule it overrides: + * split across files these two tie on specificity. */ + margin-bottom: calc(-1 * var(--vscode-spacing-size100)); + padding-bottom: var(--vscode-spacing-size100); } -.dictation-onboarding-banner { - position: relative; - display: flex; - flex-direction: column; +/* This card carries four tiers rather than the card default's two, so it takes + * the next step up the spacing ramp for both its padding and its gaps. */ +.chat-input-notice.dictation-onboarding-banner { gap: var(--vscode-spacing-size120); - box-sizing: border-box; padding: var(--vscode-spacing-size160); - border: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-widget-border, transparent)); - border-radius: var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-xSmall) var(--vscode-cornerRadius-xSmall); - background-color: var(--vscode-agentsChatInput-background, var(--vscode-input-background)); - color: var(--vscode-foreground); } /* --- Copy --- */ @@ -58,41 +54,6 @@ padding-right: var(--vscode-spacing-size240); } -.dictation-onboarding-title { - font-size: var(--vscode-fontSize-label1); - font-weight: var(--vscode-fontWeight-semiBold); -} - -/* One sentence, demoted: it explains the feature once and then supports. */ -.dictation-onboarding-description { - font-size: var(--vscode-fontSize-label2); - color: var(--vscode-descriptionForeground); - line-height: 1.45; -} - -/* - * The two ways out of the card - what dictation does, and how it writes - are - * the only actions in this tier, so they have to look like actions. Inherit the - * quiet type role and lift only the colour, so the sentence still reads as a - * sentence rather than as a row of buttons. - */ -.dictation-onboarding-description a { - color: var(--vscode-textLink-foreground); - cursor: pointer; -} - -.dictation-onboarding-description a:hover, -.dictation-onboarding-description a:active { - color: var(--vscode-textLink-activeForeground); - text-decoration: underline; -} - -.dictation-onboarding-description a:focus-visible { - outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); - outline-offset: 2px; - border-radius: var(--vscode-cornerRadius-xSmall); -} - /* --- Device --- */ /* @@ -107,43 +68,6 @@ min-width: 0; } -/* --- Microphone picker --- */ - -/* - * A real control, and it has to look like one: this is the only thing on the - * card you can act on, and a borderless row of text gave no sign it could be - * opened at all. It carries the standard dropdown surface so it reads the same - * as every other select in the product. - */ -.dictation-onboarding-picker { - position: relative; - display: flex; - align-items: center; - gap: var(--vscode-spacing-size60); - box-sizing: border-box; - min-width: 0; - height: var(--vscode-spacing-size280); - padding: 0 var(--vscode-spacing-size80); - border: var(--vscode-strokeThickness) solid var(--vscode-dropdown-border, var(--vscode-input-border, transparent)); - border-radius: var(--vscode-cornerRadius-small); - background-color: var(--vscode-dropdown-background, var(--vscode-input-background)); - transition: border-color 100ms ease-out; -} - -.dictation-onboarding-picker[hidden] { - display: none; -} - -.dictation-onboarding-picker:hover { - border-color: var(--vscode-focusBorder); -} - -.dictation-onboarding-picker:focus-within { - border-color: var(--vscode-focusBorder); - outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); - outline-offset: -2px; -} - .monaco-workbench .dictation-onboarding-picker-icon.codicon[class*='codicon-'] { flex-shrink: 0; font-size: var(--vscode-codiconFontSize-compact); @@ -151,32 +75,6 @@ color: var(--vscode-descriptionForeground); } -/* - * The select itself stays transparent so the housing above draws the surface - - * one border, not two. It is still a real select: the dropdown, the keyboard - * handling and the screen-reader semantics are untouched. - */ -.dictation-onboarding-picker .monaco-select-box { - flex: 1 1 auto; - min-width: 0; - height: 100%; - padding: 0; - border: none; - border-radius: var(--vscode-cornerRadius-small); - background-color: transparent; - color: var(--vscode-dropdown-foreground, var(--vscode-foreground)); - font-family: inherit; - font-size: var(--vscode-fontSize-label2); - text-overflow: ellipsis; -} - -/* The housing already shows focus; a second ring inside it is noise. */ -.dictation-onboarding-picker .monaco-select-box:focus { - outline: none; -} - - - /* --- Waveform --- */ /* @@ -244,45 +142,3 @@ .dictation-onboarding-banner.has-error .dictation-onboarding-hint { color: var(--vscode-inputValidation-errorForeground, var(--vscode-foreground)); } - -/* --- Confirm --- */ - -/* - * Pinned to the corner and out of the content flow, so it never competes with - * the picker for room and never moves as the card re-flows. Always available: a - * disabled dismiss would trap someone inside the card. - */ -.dictation-onboarding-close { - position: absolute; - top: var(--vscode-spacing-size80); - right: var(--vscode-spacing-size80); - display: flex; - align-items: center; - justify-content: center; - width: var(--vscode-spacing-size240); - height: var(--vscode-spacing-size240); - border-radius: var(--vscode-cornerRadius-small); - color: var(--vscode-foreground); - cursor: pointer; - transition: background-color 100ms ease-out; -} - -.dictation-onboarding-close:hover { - background-color: var(--vscode-toolbar-hoverBackground); -} - -.dictation-onboarding-close:focus-visible { - outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); - outline-offset: -1px; -} - -/* - * `.monaco-workbench .codicon` sets colour and size directly on the glyph, so - * without out-ranking it this renders as a dark 16px icon rather than a compact - * one inheriting the button's foreground. - */ -.monaco-workbench .dictation-onboarding-close .codicon[class*='codicon-'] { - font-size: var(--vscode-codiconFontSize); - line-height: 1; - color: inherit; -} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts index ef2dae068a0..24456b9b819 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts @@ -25,11 +25,17 @@ import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { CHAT_SETUP_ACTION_ID } from '../../actions/chatActions.js'; import { IChatTip, IChatTipService } from '../../chatTipService.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; +import { ChatInputNoticeVariant, ChatInputNoticeWidget } from '../input/chatInputNoticeWidget.js'; const $ = dom.$; export class ChatTipContentPart extends Disposable { - public readonly domNode: HTMLElement; + + private readonly _notice: ChatInputNoticeWidget; + + public get domNode(): HTMLElement { + return this._notice.domNode; + } private readonly _onDidHide = this._register(new Emitter()); public readonly onDidHide = this._onDidHide.event; @@ -54,10 +60,13 @@ export class ChatTipContentPart extends Disposable { ) { super(); - this.domNode = $('.chat-tip-widget'); - this.domNode.tabIndex = 0; - this.domNode.setAttribute('role', 'region'); - this.domNode.setAttribute('aria-roledescription', localize('chatTipRoleDescription', "tip")); + // Built detached: the presenter commits this part before parenting it, so + // a re-entrant render cannot leave a second tip in the container. + this._notice = this._register(new ChatInputNoticeWidget({ + variant: ChatInputNoticeVariant.Tip, + className: 'chat-tip-widget', + ariaRoleDescription: localize('chatTipRoleDescription', "tip"), + })); this._inChatTipContextKey = ChatContextKeys.inChatTip.bindTo(this._contextKeyService); this._multipleChatTipsContextKey = ChatContextKeys.multipleChatTips.bindTo(this._contextKeyService); @@ -104,11 +113,11 @@ export class ChatTipContentPart extends Disposable { } hasFocus(): boolean { - return dom.isAncestorOfActiveElement(this.domNode); + return this._notice.hasFocus(); } focus(): void { - this.domNode.focus(); + this._notice.focus(); } private _renderTip(tip: IChatTip): void { @@ -136,7 +145,7 @@ export class ChatTipContentPart extends Disposable { const ariaLabel = hasLink ? localize('chatTipWithAction', "{0} Tab to reach the action.", textContent) : textContent; - this.domNode.setAttribute('aria-label', ariaLabel); + this._notice.setAriaLabel(ariaLabel); } private async _handleTipAction(link: string, mdStr: IMarkdownString): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css index 66dbbe1f828..74efdb65a99 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css @@ -42,7 +42,7 @@ .chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar .action-item .action-label { color: var(--vscode-descriptionForeground); - padding: 4px; + padding: var(--vscode-spacing-size40); } .chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar .action-item .action-label:hover { @@ -55,30 +55,15 @@ width: 100%; } -.chat-getting-started-tip-container .chat-tip-widget { - display: flex; +/* + * The frame, row layout, type and link colours come from `.chat-input-notice`; + * only what is particular to this tip is styled here. This one line is a single + * row of text, so it centres rather than taking the variant's top alignment. + * Prefixed with the base class deliberately: without it this ties the base rule + * on specificity and which one wins would depend on stylesheet order. + */ +.chat-getting-started-tip-container .chat-input-notice.chat-tip-widget { align-items: center; - gap: 4px; - width: 100%; - max-width: 100%; - box-sizing: border-box; - padding: 6px; - background-color: var(--vscode-editorWidget-background); - border-radius: var(--vscode-cornerRadius-small) var(--vscode-cornerRadius-small) 0 0; - border: 1px solid var(--vscode-editorWidget-border, var(--vscode-input-border, transparent)); - font-size: var(--vscode-chat-font-size-body-s); - font-family: var(--vscode-chat-font-family, inherit); - color: var(--vscode-descriptionForeground); - position: relative; -} - -.chat-getting-started-tip-container .chat-tip-widget a { - color: var(--vscode-textLink-foreground); -} - -.chat-getting-started-tip-container .chat-tip-widget a:hover, -.chat-getting-started-tip-container .chat-tip-widget a:active { - color: var(--vscode-textLink-activeForeground); } .chat-getting-started-tip-container .chat-tip-widget .rendered-markdown p { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNoticeWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNoticeWidget.ts new file mode 100644 index 00000000000..c086c0f8d6a --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNoticeWidget.ts @@ -0,0 +1,245 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { addDisposableListener, EventType, isAncestorOfActiveElement, setVisibility } from '../../../../../../base/browser/dom.js'; +import { ActionBar } from '../../../../../../base/browser/ui/actionbar/actionbar.js'; +import { mainWindow } from '../../../../../../base/browser/window.js'; +import { alert, status } from '../../../../../../base/browser/ui/aria/aria.js'; +import { StandardKeyboardEvent } from '../../../../../../base/browser/keyboardEvent.js'; +import { Action } from '../../../../../../base/common/actions.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { KeyCode } from '../../../../../../base/common/keyCodes.js'; +import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { localize } from '../../../../../../nls.js'; +import { IChatInputNoticeFocusTarget } from './chatInputNoticeHost.js'; +import './media/chatInputNotice.css'; + +/** + * The visual roles a notice above a chat input can take. The border, radius, + * background and action shape are one shared rule across all of them, so those + * cannot drift apart again. + * + * What is deliberately not shared is the vertical offset between a notice and + * the input below it. That is set by whichever surface the pair sits in, and it + * is not one value: some surfaces pull the input up over the notice's bottom + * edge so the two read as a single stack, and others leave a deliberate gap. + */ +export const enum ChatInputNoticeVariant { + /** A first-run introduction. The tallest, most prominent notice. */ + Onboarding = 'onboarding', + /** A one-line getting-started hint. Yields to everything else. */ + Tip = 'tip', + /** Quota, promo, permission and extension-provided messages. */ + Notification = 'notification', +} + +export interface IChatInputNoticeWidgetOptions { + /** + * The element the notice is appended to. Omit for producers whose owner + * parents the node itself once construction has committed - a chat content + * part, whose presenter guards against a re-entrant render appending a second + * one - and parent {@link ChatInputNoticeWidget.domNode} instead. + */ + readonly container?: HTMLElement; + readonly variant: ChatInputNoticeVariant; + /** Producer-specific class, for styling the content inside the frame. */ + readonly className?: string; + /** Names the focusable region. Also what {@link ChatInputNoticeWidget.announce} speaks. */ + readonly ariaLabel?: string; + readonly ariaDescription?: string; + /** Spoken after the label, e.g. "notification". */ + readonly ariaRoleDescription?: string; + /** Called on Escape. Omit to let Escape through to the input. */ + readonly onEscape?: () => void; +} + +export interface IChatInputNoticeActionOptions { + readonly className?: string; + readonly ariaLabel: string; + readonly icon: ThemeIcon; + readonly onActivate: () => void; + /** + * Where the action is placed. Defaults to the notice itself, which pins it to + * the corner for an onboarding card and lays it out in the row for a tip. + */ + readonly parent?: HTMLElement; + /** + * Where the action's listeners are registered. Notices that rebuild their + * content pass the store scoped to one render, so repeated renders do not + * accumulate listeners for buttons that are already gone. + */ + readonly store?: DisposableStore; +} + +export type IChatInputNoticeDismissOptions = Omit & { + /** Defaults to a generic "Dismiss". */ + readonly ariaLabel?: string; +}; + +/** + * The container every notice above a chat input is built in. + * + * Owns the things all five notices have to agree on - the frame, the focusable + * ARIA region, the {@link IChatInputNoticeFocusTarget} contract the notice host + * routes focus through, the dismiss affordance, and being put away for + * higher-precedence content - so a producer only has to build its own content. + */ +export class ChatInputNoticeWidget extends Disposable implements IChatInputNoticeFocusTarget { + + readonly domNode: HTMLElement; + + private readonly _variant: ChatInputNoticeVariant; + private readonly _ariaRoleDescription: string | undefined; + private _ariaLabel: string | undefined; + private _visible = true; + + constructor(options: IChatInputNoticeWidgetOptions) { + super(); + + this._variant = options.variant; + this._ariaRoleDescription = options.ariaRoleDescription; + + // Detached notices are created in the main window's document, the same as + // `dom.$` does, and adopted when their owner parents them. + this.domNode = (options.container?.ownerDocument ?? mainWindow.document).createElement('div'); + this.domNode.classList.add('chat-input-notice', `chat-input-notice-${options.variant}`); + if (options.className) { + this.domNode.classList.add(options.className); + } + if (options.ariaDescription) { + this.domNode.setAttribute('aria-description', options.ariaDescription); + } + this.setAriaLabel(options.ariaLabel); + + options.container?.appendChild(this.domNode); + this._register(toDisposable(() => this.domNode.remove())); + + const onEscape = options.onEscape; + if (onEscape) { + this._register(addDisposableListener(this.domNode, EventType.KEY_DOWN, event => { + const keyboardEvent = new StandardKeyboardEvent(event); + if (keyboardEvent.equals(KeyCode.Escape)) { + keyboardEvent.preventDefault(); + keyboardEvent.stopPropagation(); + onEscape(); + } + })); + } + } + + /** + * Names the region. Notices whose message is only known per-render - a + * notification, a tip being navigated - set this as they build their content. + */ + setAriaLabel(ariaLabel: string | undefined): void { + this._ariaLabel = ariaLabel; + this._applyRegionAttributes(); + } + + /** + * A notice is only a landmark and a tab stop while it is actually on screen. + * Left in place while put away, it would be an unlabelled region the user can + * still tab into and find nothing in. + */ + private _applyRegionAttributes(): void { + if (this._visible) { + this.domNode.setAttribute('role', 'region'); + // Reachable by the notice focus command, like every other notice above an input. + this.domNode.tabIndex = 0; + if (this._ariaRoleDescription) { + this.domNode.setAttribute('aria-roledescription', this._ariaRoleDescription); + } + if (this._ariaLabel) { + this.domNode.setAttribute('aria-label', this._ariaLabel); + } else { + this.domNode.removeAttribute('aria-label'); + } + } else { + this.domNode.removeAttribute('role'); + this.domNode.removeAttribute('tabindex'); + this.domNode.removeAttribute('aria-roledescription'); + this.domNode.removeAttribute('aria-label'); + } + } + + /** + * Speak the notice as it reaches the screen. A tip is advisory - it is in the + * lane that yields to everything else - so it is announced politely and waits + * its turn; an introduction or a notification is why the user's attention was + * wanted in the first place, so it interrupts. + */ + announce(): void { + if (!this._ariaLabel) { + return; + } + + const message = localize('chatInputNotice.focusHint', "{0}. Use Shift+Tab to reach the notice.", this._ariaLabel); + if (this._variant === ChatInputNoticeVariant.Tip) { + status(message); + } else { + alert(message); + } + } + + hasFocus(): boolean { + return isAncestorOfActiveElement(this.domNode); + } + + focus(): void { + this.domNode.focus(); + } + + /** + * Called when the notice is put away for higher-precedence content, and again + * when it comes back. The notice is kept alive across this, so a producer with + * live parts - microphone capture, audio, animation - calls this and stands + * those down too rather than keep them going where the user cannot see them. + */ + setVisible(visible: boolean): void { + if (this._visible === visible) { + return; + } + + this._visible = visible; + setVisibility(visible, this.domNode); + this._applyRegionAttributes(); + } + + /** + * An icon button, in the shape every notice's actions share. Built on + * `ActionBar` so keyboard handling, touch, focus and theming come from the + * shared action infrastructure rather than being re-implemented per notice. + */ + addAction(options: IChatInputNoticeActionOptions): HTMLElement { + const register = (disposable: T): T => options.store ? options.store.add(disposable) : this._register(disposable); + + const container = this.domNode.ownerDocument.createElement('div'); + container.classList.add('chat-input-notice-action'); + (options.parent ?? this.domNode).appendChild(container); + register(toDisposable(() => container.remove())); + + // The producer's class goes on the action itself rather than the housing, so + // it names the thing that is actually clicked, focused and styled. + const cssClass = [ThemeIcon.asClassName(options.icon), options.className].filter(Boolean).join(' '); + const actionBar = register(new ActionBar(container)); + actionBar.push(register(new Action('chatInputNotice.action', options.ariaLabel, cssClass, true, async () => options.onActivate())), { icon: true, label: false }); + + return container; + } + + /** + * The standard way out of a notice. Kept separate from {@link addAction} so + * every notice's dismiss reads and behaves the same, wherever it appears. + */ + addDismissAction(options: IChatInputNoticeDismissOptions): HTMLElement { + return this.addAction({ + ...options, + ariaLabel: options.ariaLabel ?? localize('chatInputNotice.dismiss', "Dismiss"), + icon: Codicon.closeCompact, + className: [options.className, 'chat-input-notice-dismiss'].filter(Boolean).join(' '), + }); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.ts index 6f4463eeab2..37d04d5b6f8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.ts @@ -21,6 +21,7 @@ import { IMarkdownRendererService } from '../../../../../../platform/markdown/br import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { defaultButtonStyles } from '../../../../../../platform/theme/browser/defaultStyles.js'; import { IChatInputNoticeFocusTarget } from './chatInputNoticeHost.js'; +import { ChatInputNoticeVariant, ChatInputNoticeWidget } from './chatInputNoticeWidget.js'; import { ChatInputNotificationActionKind, ChatInputNotificationSeverity, IChatInputNotification, IChatInputNotificationAction, IChatInputNotificationCommandAction, IChatInputNotificationService, isChatInputNotificationApplicableToSession } from './chatInputNotificationService.js'; import './media/chatInputNotificationWidget.css'; @@ -88,7 +89,11 @@ export interface IChatInputNotificationDelegate { */ export class ChatInputNotificationWidget extends Disposable implements IChatInputNoticeFocusTarget { - readonly domNode: HTMLElement; + private readonly _notice: ChatInputNoticeWidget; + + get domNode(): HTMLElement { + return this._notice.domNode; + } private readonly _contentDisposables = this._register(new DisposableStore()); private _lastShownTelemetryData: ChatInputNotificationTelemetryEvent | undefined; @@ -107,7 +112,14 @@ export class ChatInputNotificationWidget extends Disposable implements IChatInpu ) { super(); - this.domNode = $('.chat-input-notification-widget'); + // Built detached: the input part parents this widget itself, into the lane + // it lays out above the input. + this._notice = this._register(new ChatInputNoticeWidget({ + variant: ChatInputNoticeVariant.Notification, + className: 'chat-input-notification-widget', + ariaRoleDescription: localize('chatInputNotificationRoleDescription', "notification"), + })); + this._notice.setVisible(false); this._register(this._notificationService.onDidChange(() => this._render())); this._register(autorun(reader => { @@ -124,6 +136,7 @@ export class ChatInputNotificationWidget extends Disposable implements IChatInpu const hadFocus = this.hasFocus(); this._contentDisposables.clear(); dom.clearNode(this.domNode); + this.domNode.classList.remove(...Object.values(severityToClass)); const notification = this._notificationService.getActiveNotification(n => this._matchesSession(n)); this._setVisible(!!notification); @@ -154,27 +167,16 @@ export class ChatInputNotificationWidget extends Disposable implements IChatInpu } this._visible = visible; - // The widget element outlives any one notification, so it only carries the - // region role and a tab stop while it actually renders something. - if (visible) { - this.domNode.tabIndex = 0; - this.domNode.setAttribute('role', 'region'); - this.domNode.setAttribute('aria-roledescription', localize('chatInputNotificationRoleDescription', "notification")); - } else { - this.domNode.removeAttribute('tabindex'); - this.domNode.removeAttribute('role'); - this.domNode.removeAttribute('aria-roledescription'); - this.domNode.removeAttribute('aria-label'); - } + this._notice.setVisible(visible); this._delegate?.onDidChangeVisibility?.(visible, this); } hasFocus(): boolean { - return dom.isAncestorOfActiveElement(this.domNode); + return this._notice.hasFocus(); } focus(): void { - this.domNode.focus(); + this._notice.focus(); } private _matchesSession(notification: IChatInputNotification): boolean { @@ -182,9 +184,7 @@ export class ChatInputNotificationWidget extends Disposable implements IChatInpu } private _renderNotification(notification: IChatInputNotification): void { - const container = dom.append(this.domNode, $('.chat-input-notification')); - - // Apply severity class + const container = this.domNode; container.classList.add(severityToClass[notification.severity]); // Header row: icon + title + mute + dismiss @@ -206,60 +206,47 @@ export class ChatInputNotificationWidget extends Disposable implements IChatInpu const ariaTitle = isMarkdownString(notification.message) ? notification.message.value : notification.message; // Names the focusable region: `aria-roledescription` alone would have focus // land on something announced only as "notification". - this.domNode.setAttribute('aria-label', ariaTitle); + this._notice.setAriaLabel(ariaTitle); if (notification.mute) { const mute = notification.mute; - const muteButton = dom.append(headerRow, $('.chat-input-notification-mute')); - muteButton.appendChild(dom.$(ThemeIcon.asCSSSelector(Codicon.bellSlash))); - muteButton.tabIndex = 0; - muteButton.role = 'button'; - muteButton.ariaLabel = mute.tooltip; - this._contentDisposables.add(this._hoverService.setupManagedHover(getDefaultHoverDelegate('element'), muteButton, mute.tooltip)); // Defer to a microtask for the same reason as the dismiss button: // the command synchronously tears down the notification, and the // resulting re-render must happen after the click has propagated. - const doMute = () => queueMicrotask(() => { - this._telemetryService.publicLog2('workbenchActionExecuted', { - id: mute.commandId, - from: 'chatInputNotification', - }); - this._commandService.executeCommand(mute.commandId, ...(mute.commandArgs ?? [])); + const muteButton = this._notice.addAction({ + ariaLabel: mute.tooltip, + icon: Codicon.bellSlash, + parent: headerRow, + store: this._contentDisposables, + onActivate: () => queueMicrotask(() => { + this._telemetryService.publicLog2('workbenchActionExecuted', { + id: mute.commandId, + from: 'chatInputNotification', + }); + this._commandService.executeCommand(mute.commandId, ...(mute.commandArgs ?? [])); + }), }); - this._contentDisposables.add(dom.addDisposableListener(muteButton, dom.EventType.CLICK, doMute)); - this._contentDisposables.add(dom.addDisposableListener(muteButton, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - doMute(); - } - })); + this._contentDisposables.add(this._hoverService.setupManagedHover(getDefaultHoverDelegate('element'), muteButton, mute.tooltip)); } // Dismiss button (in header row, pushed to the right) if (notification.dismissible) { - const dismissButton = dom.append(headerRow, $('.chat-input-notification-dismiss')); - dismissButton.appendChild(dom.$(ThemeIcon.asCSSSelector(Codicon.close))); - dismissButton.tabIndex = 0; - dismissButton.role = 'button'; - dismissButton.ariaLabel = localize('dismissNotification', "Dismiss notification"); - // Defer the dismiss to a microtask so the synchronous re-render // (which clears all children of the widget) happens after the // browser has finished propagating the click event. Otherwise // blur handlers fired by removing the button from focus can // move/remove nodes that `clearNode` then trips over. - const dismiss = () => queueMicrotask(() => { - this._telemetryService.publicLog2('chatInputNotificationDismissed', this._getTelemetryData(notification)); - this._notificationService.dismissNotification(notification.id); + this._notice.addDismissAction({ + className: 'chat-input-notification-dismiss', + ariaLabel: localize('dismissNotification', "Dismiss notification"), + parent: headerRow, + store: this._contentDisposables, + onActivate: () => queueMicrotask(() => { + this._telemetryService.publicLog2('chatInputNotificationDismissed', this._getTelemetryData(notification)); + this._notificationService.dismissNotification(notification.id); + }), }); - this._contentDisposables.add(dom.addDisposableListener(dismissButton, dom.EventType.CLICK, dismiss)); - this._contentDisposables.add(dom.addDisposableListener(dismissButton, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - dismiss(); - } - })); } // Body row: description + actions on the same line diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputOnboarding.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputOnboarding.ts index 7baba970d4c..91bc3bdcb7e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputOnboarding.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputOnboarding.ts @@ -3,14 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { addDisposableListener, EventType, isAncestorOfActiveElement, setVisibility } from '../../../../../../base/browser/dom.js'; -import { alert } from '../../../../../../base/browser/ui/aria/aria.js'; -import { StandardKeyboardEvent } from '../../../../../../base/browser/keyboardEvent.js'; +import { setVisibility } from '../../../../../../base/browser/dom.js'; import { onUnexpectedError } from '../../../../../../base/common/errors.js'; -import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; -import { ThemeIcon } from '../../../../../../base/common/themables.js'; -import { localize } from '../../../../../../nls.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { IChatInputNoticeClaimOptions, IChatInputNoticeFocusTarget, IChatInputSurface, pickActiveChatInput, trackChatInputRecency } from './chatInputNoticeHost.js'; @@ -63,21 +58,6 @@ export interface IChatInputOnboardingBanner extends IDisposable, IChatInputNotic setVisible?(visible: boolean): void; } -export interface IChatInputOnboardingCardOptions { - readonly container: HTMLElement; - readonly className: string; - readonly ariaLabel: string; - readonly ariaDescription?: string; - readonly onEscape: () => void; -} - -export interface IChatInputOnboardingActionOptions { - readonly className: string; - readonly ariaLabel: string; - readonly icon: ThemeIcon; - readonly onActivate: () => void; -} - export class ChatInputOnboarding extends Disposable { private readonly hosts = new Set(); @@ -315,76 +295,3 @@ export class ChatInputOnboarding extends Disposable { } } } - -export class ChatInputOnboardingCard extends Disposable { - - readonly domNode: HTMLElement; - - private readonly ariaLabel: string; - - constructor(options: IChatInputOnboardingCardOptions) { - super(); - - this.ariaLabel = options.ariaLabel; - - this.domNode = options.container.ownerDocument.createElement('div'); - this.domNode.classList.add(options.className); - this.domNode.setAttribute('role', 'region'); - this.domNode.setAttribute('aria-label', options.ariaLabel); - if (options.ariaDescription) { - this.domNode.setAttribute('aria-description', options.ariaDescription); - } - - options.container.appendChild(this.domNode); - this._register(toDisposable(() => this.domNode.remove())); - - this.domNode.tabIndex = 0; - - this._register(addDisposableListener(this.domNode, EventType.KEY_DOWN, event => { - const keyboardEvent = new StandardKeyboardEvent(event); - if (keyboardEvent.equals(KeyCode.Escape)) { - keyboardEvent.preventDefault(); - keyboardEvent.stopPropagation(); - options.onEscape(); - } - })); - } - - announce(): void { - alert(localize('chatInputOnboarding.focusHint', "{0}. Use Shift+Tab to reach the introduction.", this.ariaLabel)); - } - - hasFocus(): boolean { - return isAncestorOfActiveElement(this.domNode); - } - - focus(): void { - this.domNode.focus(); - } - - addAction(options: IChatInputOnboardingActionOptions): HTMLElement { - const action = this.domNode.ownerDocument.createElement('div'); - action.classList.add(options.className); - action.setAttribute('role', 'button'); - action.tabIndex = 0; - action.setAttribute('aria-label', options.ariaLabel); - const icon = this.domNode.ownerDocument.createElement('span'); - icon.classList.add(...ThemeIcon.asClassNameArray(options.icon)); - icon.setAttribute('aria-hidden', 'true'); - action.appendChild(icon); - this.domNode.appendChild(action); - - const activate = () => options.onActivate(); - this._register(addDisposableListener(action, EventType.CLICK, activate)); - this._register(addDisposableListener(action, EventType.KEY_DOWN, event => { - const keyboardEvent = new StandardKeyboardEvent(event); - if (keyboardEvent.equals(KeyCode.Enter) || keyboardEvent.equals(KeyCode.Space)) { - keyboardEvent.preventDefault(); - keyboardEvent.stopPropagation(); - activate(); - } - })); - - return action; - } -} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotice.css b/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotice.css new file mode 100644 index 00000000000..5cbb6929d16 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/media/chatInputNotice.css @@ -0,0 +1,264 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* + * The notice above a chat input. Five producers - notification, voice and + * dictation introductions, getting-started tip, sub-session tip - share this + * frame so the space reads as one surface no matter who is filling it. + * + * This is a baseline, not a contract: a producer can still re-specialise its + * padding or density. What it should not have to restate is the border, the + * radius, the background and the action shape - the things that were drifting. + */ + +/* + * One thing is deliberately NOT shared: how far a notice sits from the input + * below it. A notice has a square bottom edge and the input has rounded top + * corners, so most places pull the input up over that edge - by 4 to 8px - to + * hide the join. Inline chat wants the opposite and leaves a 16px gap. + * + * The right number depends on the surrounding layout, so each place sets its own + * next to the rule it overrides. Sharing it from here has been tried twice and + * broke both times: once because the shared value slipped past overrides that + * only set `margin-bottom`, once because a hide/show pair split across two files + * ended up equally specific, so whichever file loaded last won. + */ + +/* --- The frame --- */ + +.chat-input-notice { + position: relative; + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size80); + box-sizing: border-box; + width: 100%; + max-width: 100%; + padding: var(--vscode-spacing-size120); + border: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-widget-border, transparent)); + /* Square at the bottom, and rounded at the top on the same ramp the input uses, + * so the notice reads as the top of the input rather than a box resting on it. + * The bottom edge is left open: the notice sits against the input, and drawing + * a border there puts a line across what should read as one surface. */ + border-bottom: none; + border-radius: var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-large) 0 0; + background-color: var(--vscode-agentsChatInput-background, var(--vscode-input-background)); + color: var(--vscode-foreground); +} + +.chat-input-notice:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +/* + * A tip and a notification are both prose about the conversation, so they take + * the chat type ramp and the demoted foreground. An introduction is a card in + * its own right and keeps the base foreground and the label ramp its own + * content sets. Shared here so a fourth kind of notice does not restate it. + */ +.chat-input-notice.chat-input-notice-tip, +.chat-input-notice.chat-input-notice-notification { + font-size: var(--vscode-chat-font-size-body-s); + font-family: var(--vscode-chat-font-family, inherit); + color: var(--vscode-descriptionForeground); +} + +/* + * Links, wherever they appear in a notice - prose in a tip, markdown in a + * notification, the settings link in an introduction. Anchors are not covered by + * the workbench's global focus rule, so without the `:focus-visible` outline + * here a keyboard user tabbing to one of these gets no indication of where they + * are - which a getting-started tip actively invites them to do. + */ +.chat-input-notice a, +.chat-input-notice a:visited { + color: var(--vscode-textLink-foreground); + cursor: pointer; + border-radius: var(--vscode-cornerRadius-xSmall); +} + +.chat-input-notice a:hover, +.chat-input-notice a:active { + color: var(--vscode-textLink-activeForeground); + text-decoration: underline; +} + +.chat-input-notice a:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: 2px; +} + +/* --- Variant: tip --- */ + +/* One line of supporting prose next to an icon: a row, and the densest of the three. */ +.chat-input-notice.chat-input-notice-tip { + flex-direction: row; + align-items: flex-start; + gap: var(--vscode-spacing-size60); + padding: var(--vscode-spacing-size60) var(--vscode-spacing-size80); +} + +/* + * A card is the exception. In the panel layout it sits a few pixels clear of the + * input rather than against it, so it needs its bottom edge to close the box. + */ +.chat-input-notice.chat-input-notice-onboarding { + border-bottom: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-widget-border, transparent)); +} + +/* --- Variant: notification --- */ + +/* + * Severity tints the same frame rather than replacing it: the notice keeps its + * shape and only its border and wash say how urgent it is. Each severity names + * its colour once and the border, the wash and the icon all derive from it, so + * a new severity is one declaration rather than three across two files. + */ +.chat-input-notice.chat-input-notice-notification { + gap: var(--vscode-spacing-size20); + border-color: var(--chat-input-notice-severity); + background-color: color-mix(in srgb, var(--chat-input-notice-severity) 6%, var(--vscode-agentsChatInput-background, var(--vscode-input-background))); +} + +.chat-input-notice.chat-input-notice-notification.severity-info { + --chat-input-notice-severity: var(--vscode-focusBorder); + /* The quiet one: it washes and colours its icon, but keeps the plain input + * border rather than tinting it. */ + border-color: var(--vscode-input-border, transparent); +} + +.chat-input-notice.chat-input-notice-notification.severity-warning { + --chat-input-notice-severity: var(--vscode-editorWarning-foreground); +} + +.chat-input-notice.chat-input-notice-notification.severity-error { + --chat-input-notice-severity: var(--vscode-editorError-foreground); +} + +/* --- Onboarding card: copy --- */ + +/* + * A card names itself and then explains itself once. Both cards use the same two + * type roles, so they are set here rather than restated per card. + */ +.chat-input-notice-title { + font-size: var(--vscode-fontSize-label1); + font-weight: var(--vscode-fontWeight-semiBold); +} + +/* + * Wraps rather than truncating: this sentence carries the promise, the ask and + * the escape hatch, so it survives at every width. + */ +.chat-input-notice-description { + font-size: var(--vscode-fontSize-label2); + color: var(--vscode-descriptionForeground); + line-height: 1.4; +} + +/* --- Onboarding card: device picker --- */ + +/* + * The housing for a device