Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build/lib/stylelint/vscode-known-variables.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
"--vscode-agentSessionSelectedBadge-border",
"--vscode-agentSessionSelectedUnfocusedBadge-border",
"--vscode-agentStatusIndicator-background",
"--vscode-agentsVoice-speakingBackground",
"--vscode-agentsVoice-speakingForeground",
"--vscode-activeSessionView-background",
"--vscode-activeSessionView-foreground",
"--vscode-inactiveSessionView-background",
Expand Down Expand Up @@ -1021,6 +1023,7 @@
"--pane-header-size",
"--model-hover-surface-background",
"--scroll-shadow-surface",
"--voice-mode-onboarding-surface",
"--vscode-chat-list-background",
"--vscode-editorCodeLens-fontFamily",
"--vscode-editorCodeLens-fontFamilyDefault",
Expand Down
6 changes: 6 additions & 0 deletions src/vs/sessions/contrib/chat/browser/newChatInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actio
import { DictationDownloadRing, getDictationDownloadHoverMarkdown, getDictationPreparingLabel } from '../../../../workbench/contrib/chat/browser/speechToText/dictationDownloadRing.js';
import { IVoiceSessionController } from '../../../../workbench/contrib/chat/browser/voiceClient/voiceSessionController.js';
import { ChatPetWidget } from '../../../../workbench/contrib/chat/browser/widget/chatPetWidget.js';
import { IVoiceModeOnboardingService } from '../../../../workbench/contrib/agentsVoice/browser/voiceModeOnboarding.js';


const OPEN_OTEL_SETTINGS_COMMAND = 'github.copilot.chat.otel.openSettings';
Expand Down Expand Up @@ -376,6 +377,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation
@IVoiceSessionController private readonly voiceSessionController: IVoiceSessionController,
@IVoiceInputModeService private readonly voiceInputModeService: IVoiceInputModeService,
@IAccessibilityService private readonly accessibilityService: IAccessibilityService,
@IVoiceModeOnboardingService private readonly voiceModeOnboardingService: IVoiceModeOnboardingService,
) {
super();
this._sessionModelSelectionModel = this._register(this.instantiationService.createInstance(SessionModelSelectionModel, this.options.session));
Expand Down Expand Up @@ -451,6 +453,10 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation
));
notificationContainer.appendChild(notificationWidget.domNode);

// First-run Voice Mode introduction, docked above the input area
const voiceOnboardingContainer = dom.append(chatInputContainer, dom.$('.voice-mode-onboarding-container'));
this._register(this.voiceModeOnboardingService.registerHost(voiceOnboardingContainer, chatInputContainer));

// Input area inside the input slot
const inputAreaWrapper = dom.append(chatInputContainer, dom.$('.new-chat-input-area-wrapper'));
const inputArea = dom.append(inputAreaWrapper, dom.$('.new-chat-input-area'));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js';
import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js';
import { ChatAgentLocation } from '../../chat/common/constants.js';
import { ICommandService } from '../../../../platform/commands/common/commands.js';
import { IVoiceModeOnboardingService } from './voiceModeOnboarding.js';

// --- Context Keys ---

Expand Down Expand Up @@ -112,6 +113,35 @@ class AgentsVoiceTelemetryContribution extends Disposable implements IWorkbenchC

registerWorkbenchContribution2(AgentsVoiceTelemetryContribution.ID, AgentsVoiceTelemetryContribution, WorkbenchPhase.AfterRestored);

// --- First-run introduction ---

/**
* Shows the Voice Mode introduction the first time a session starts. This
* watches the connection state rather than any one entry point, because Voice
* Mode can be started from the input-mode pill, a command, a keybinding or the
* Agents window - all of which land here.
*/
class AgentsVoiceOnboardingContribution extends Disposable implements IWorkbenchContribution {
static readonly ID = 'workbench.contrib.agentsVoiceOnboarding';

constructor(
@IVoiceSessionController voiceSessionController: IVoiceSessionController,
@IVoiceModeOnboardingService voiceModeOnboardingService: IVoiceModeOnboardingService,
) {
super();

this._register(autorun(reader => {
if (voiceSessionController.isConnecting.read(reader) || voiceSessionController.isConnected.read(reader)) {
voiceModeOnboardingService.showIfNeeded();
}
}));
}
}

// Registered at the same late phase as the connected-key contribution so it
// does not force `IVoiceSessionController` to instantiate early.
registerWorkbenchContribution2(AgentsVoiceOnboardingContribution.ID, AgentsVoiceOnboardingContribution, WorkbenchPhase.Eventually);

// --- Voice mode button in Chat toolbar ---
// Shows the voice mode icon in both idle and active states.
// Click to connect if disconnected, or toggle PTT if connected.
Expand Down Expand Up @@ -407,6 +437,7 @@ registerAction2(class extends Action2 {
async run(accessor: ServicesAccessor): Promise<void> {
const storageService = accessor.get(IStorageService);
storageService.remove(AgentsVoiceStorageKeys.OnboardingCompleted, StorageScope.PROFILE);
storageService.remove(AgentsVoiceStorageKeys.IntroBannerShown, StorageScope.APPLICATION);
}
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

/*
* The Voice Mode introduction card.
*
* One layout at every width - a vertical stack - because the chat panel is
* routinely ~250px and a card that re-tiers as it narrows never settles into a
* rhythm. Stacked, the spacing is identical everywhere and nothing has to be
* dropped, scrolled or clipped.
*
* Spacing rhythm, all on the 8px scale:
* 12px card padding
* 8px between tiers
* 6px between voice chips
* 2px between title and its sentence
*/

/* The host is a peer directly above the chat input, and stays out of the layout
* entirely until the card is attached. */
.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);
}

/* --- Copy --- */

.voice-mode-onboarding-copy {
display: flex;
flex-direction: column;
gap: var(--vscode-spacing-size20);
min-width: 0;
/* Clear of the pinned close button. */
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 and the
* dictation alternative, so it survives at every width. */
line-height: 1.4;
}

/* --- Waveform --- */

/*
* The waveform gets a row of its own. Earlier it sat behind the copy, which
* forced a scrim over it, which erased it - a waveform competing with a
* paragraph is a fight neither side wins. With its own space it needs no mask,
* no scrim and no dimming, so it can simply be seen.
*/
.voice-mode-onboarding-wave {
height: 34px;
pointer-events: none;
/* Eases into the card's edges rather than being sliced off at them. */
mask-image: linear-gradient(to right, transparent 0%, rgba(0, 0, 0, 1) 12%, rgba(0, 0, 0, 1) 88%, transparent 100%);
}

.voice-mode-onboarding-canvas {
display: block;
width: 100%;
height: 100%;
}

/* --- Voices and confirmation --- */

.voice-mode-onboarding-actions {
display: flex;
align-items: center;
gap: var(--vscode-spacing-size80);
}

/* --- Voice options --- */

/*
* The options are buttons and have to look like it. Bare text gave no sign it
* could be clicked, let alone that clicking would speak *and* stick.
*/
.voice-mode-onboarding-voices {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--vscode-spacing-size20);
flex: 1 1 auto;
min-width: 0;
}

.voice-mode-onboarding-voice {
display: flex;
align-items: center;
gap: var(--vscode-spacing-size20);
padding: var(--vscode-spacing-size20) var(--vscode-spacing-size40);
border: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-widget-border, transparent));
border-radius: var(--vscode-cornerRadius-medium);
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
font-size: var(--vscode-fontSize-label2);
white-space: nowrap;
cursor: pointer;
transition: background-color 100ms ease-out, border-color 100ms ease-out;
}

.voice-mode-onboarding-voice:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
border-color: var(--vscode-agentsVoice-speakingForeground);
}

/* Pressed feedback, so a click is felt as well as seen. */
.voice-mode-onboarding-voice:active {
transform: translateY(1px);
}

.voice-mode-onboarding-voice:focus-visible {
outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder);
outline-offset: 2px;
}

.voice-mode-onboarding-voice.selected {
background-color: var(--vscode-agentsVoice-speakingBackground);
border-color: var(--vscode-agentsVoice-speakingForeground);
color: var(--vscode-agentsVoice-speakingForeground);
font-weight: var(--vscode-fontWeight-semiBold);
}

/* --- The icon carries the story --- */

/*
* One slot, three states. Play says "this will speak", the bars say "it is
* speaking", the check says "this one is yours". Stacking them in a fixed-size
* slot means swapping state never reflows the row.
*/
/*
* The slot collapses to nothing at rest and slides open on hover or once the
* voice is chosen - the same move the Voice Mode pill makes with its own cells.
* That is what lets four named buttons share one row in a narrow panel.
*/
.voice-mode-onboarding-voice-icon {
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 0;
height: var(--vscode-codiconFontSize-compact, 12px);
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);
}

.voice-mode-onboarding-voice:hover .voice-mode-onboarding-voice-icon,
.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);
margin-right: var(--vscode-spacing-size20);
}

.voice-mode-onboarding-voice-icon > * {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 120ms ease-out;
}

.monaco-workbench .voice-mode-onboarding-voice-icon .codicon[class*='codicon-'] {
font-size: var(--vscode-codiconFontSize-compact, 12px);
line-height: 1;
color: inherit;
}

/* Idle: the play glyph, brightening on hover. */
.voice-mode-onboarding-voice .voice-mode-onboarding-voice-idle {
opacity: 0.6;
}

.voice-mode-onboarding-voice:hover .voice-mode-onboarding-voice-idle {
opacity: 1;
}

/* Chosen: the check replaces play, so the row says which voice is in use. */
.voice-mode-onboarding-voice.selected .voice-mode-onboarding-voice-idle {
opacity: 0;
}

.voice-mode-onboarding-voice.selected .voice-mode-onboarding-voice-chosen {
opacity: 1;
}

/* Speaking: miniature bars, the same instrument as the big waveform. */
.voice-mode-onboarding-voice.playing .voice-mode-onboarding-voice-idle,
.voice-mode-onboarding-voice.playing .voice-mode-onboarding-voice-chosen {
opacity: 0;
}

.voice-mode-onboarding-voice.playing .voice-mode-onboarding-voice-bars {
opacity: 1;
}

.voice-mode-onboarding-voice-bars {
gap: 1px;
height: var(--vscode-codiconFontSize-compact, 12px);
}

.voice-mode-onboarding-voice-bar {
width: 1.5px;
height: 4px;
border-radius: 1px;
background: currentColor;
}

.monaco-workbench.monaco-enable-motion .voice-mode-onboarding-voice.playing .voice-mode-onboarding-voice-bar {
animation: voice-mode-onboarding-eq 0.85s ease-in-out infinite;
}

.monaco-workbench.monaco-enable-motion .voice-mode-onboarding-voice.playing .voice-mode-onboarding-voice-bar:nth-child(2) {
animation-delay: 0.12s;
}

.monaco-workbench.monaco-enable-motion .voice-mode-onboarding-voice.playing .voice-mode-onboarding-voice-bar:nth-child(3) {
animation-delay: 0.24s;
}

/* Mirrors `chat-voice-input-mode-eq`, so the two waveforms move alike. */
@keyframes voice-mode-onboarding-eq {
0%, 100% { height: 3px; }
50% { height: 10px; }
}

/* --- 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;
}
Loading
Loading