Skip to content

Commit bf370d6

Browse files
eli-w-kingCopilot
andcommitted
Address review: keep the microphone shut, and finish the details
The card promised Voice Mode was connected but not listening while the user read it, and did not deliver. It goes up on `isConnecting`, before a session exists, so its `stopListening()` was a no-op - that method returns early while `!isConnected`. Session init then called `_enterAutoListen('connect')` and opened the microphone with the card still on screen, which is the one thing it said it would not do. Add `setAutoListenHeld()`: a hold that can be taken before the session connects and survives the handshake. It is a separate flag from `_autoListenSuppressed` on purpose - pttDown, playback prep and disconnect all clear that one as part of normal turn-taking, so a hold riding on it would be released by the first thing that happened. The card takes the hold for its whole lifetime and releasing it is what resumes hands-free, replacing the synthetic press `finish()` used to fake. Also from review: - The one-time key was written before the card was built, so a card that threw on the way up would consume an appearance nobody saw. It is now written last, once the card is attached. - Dismissing from the keyboard dropped focus on the document body. Hosts now pass a focus callback - they know where their caret belongs, and a container element is not a control - and it is only used when focus is still inside the card, so clicking away is not undone. - `phase` was left out of the signature morph. It is a static per- component offset, so Kevin and Daniel never actually reached the signatures declared for them. - The resting trace claimed to be a blend of all four voices and was quietly Victoria's. It is now the mean it always said it was. - Reduced motion stopped the keyframes but not the transitions, so the icon slot still slid open and its glyphs still crossfaded. - Both 12px icons now use the compact glyphs drawn for that size. - Chat accessibility help says nothing about a new interactive surface, so it now describes reaching, using and dismissing the card. - The spacing note claimed 6px chip gaps on an "8px scale"; both were wrong. Named the tokens instead. - Dropped an allowlisted custom property that nothing declares or uses. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c0fa3c0 commit bf370d6

8 files changed

Lines changed: 242 additions & 44 deletions

File tree

build/lib/stylelint/vscode-known-variables.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1023,7 +1023,6 @@
10231023
"--pane-header-size",
10241024
"--model-hover-surface-background",
10251025
"--scroll-shadow-surface",
1026-
"--voice-mode-onboarding-surface",
10271026
"--vscode-chat-list-background",
10281027
"--vscode-editorCodeLens-fontFamily",
10291028
"--vscode-editorCodeLens-fontFamilyDefault",

src/vs/sessions/contrib/chat/browser/newChatInput.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation
455455

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

460460
// Input area inside the input slot
461461
const inputAreaWrapper = dom.append(chatInputContainer, dom.$('.new-chat-input-area-wrapper'));

src/vs/workbench/contrib/agentsVoice/browser/media/voiceModeOnboarding.css

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@
1111
* rhythm. Stacked, the spacing is identical everywhere and nothing has to be
1212
* dropped, scrolled or clipped.
1313
*
14-
* Spacing rhythm, all on the 8px scale:
15-
* 12px card padding
16-
* 8px between tiers
17-
* 6px between voice chips
18-
* 2px between title and its sentence
14+
* Spacing rhythm, all from the shared spacing ramp:
15+
* 12px card padding (size120)
16+
* 8px between tiers, and voices to close (size80)
17+
* 2px between voice chips, and title to its sentence (size20)
18+
* 2/4px inside a voice chip (size20 / size40)
1919
*/
2020

2121
/* The host is a peer directly above the chat input, and stays out of the layout
@@ -275,6 +275,20 @@
275275
50% { height: 10px; }
276276
}
277277

278+
/* --- Reduced motion --- */
279+
280+
/*
281+
* The keyframe animations above are already opt-in via `.monaco-enable-motion`,
282+
* but transitions are not: without this the icon slot would still slide open
283+
* over 200ms and its glyphs still crossfade, which is exactly the movement the
284+
* preference asks us not to make. Colour changes are left alone - they convey
285+
* state rather than motion.
286+
*/
287+
.monaco-reduce-motion .voice-mode-onboarding-voice-icon,
288+
.monaco-reduce-motion .voice-mode-onboarding-voice-icon > * {
289+
transition: none;
290+
}
291+
278292
/* --- Close --- */
279293

280294
/*

src/vs/workbench/contrib/agentsVoice/browser/voiceModeOnboarding.ts

Lines changed: 67 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,25 @@ const VOICES: readonly IVoiceModeVoice[] = [
104104
},
105105
];
106106

107+
/**
108+
* The trace before anyone has chosen: the four signatures averaged component by
109+
* component, so it belongs to no voice in particular rather than quietly being
110+
* the first one in the list. The declared phases all sit within a couple of
111+
* radians of each other, so a plain mean lands between them rather than on the
112+
* far side of the circle.
113+
*/
114+
const RESTING_SIGNATURE: readonly IWave[] = VOICES[0].signature.map((_, index) => {
115+
const components = VOICES.map(voice => voice.signature[index]);
116+
const mean = (pick: (wave: IWave) => number) =>
117+
components.reduce((sum, wave) => sum + pick(wave), 0) / components.length;
118+
return {
119+
frequency: mean(wave => wave.frequency),
120+
amplitude: mean(wave => wave.amplitude),
121+
speed: mean(wave => wave.speed),
122+
phase: mean(wave => wave.phase),
123+
};
124+
});
125+
107126
// --- ASCII waveform ------------------------------------------------------
108127

109128
/** Amplitude with nothing playing: present, but clearly at rest. */
@@ -135,12 +154,19 @@ function cloneSignature(signature: readonly IWave[]): MutableWave[] {
135154
* Ease a signature towards a target in place. Morphing the numbers rather than
136155
* swapping them is what makes a voice change read as the trace *becoming* the
137156
* new voice instead of cutting to it.
157+
*
158+
* `phase` eases with the rest: it is a static offset per component (the motion
159+
* comes from `time * speed`), so leaving it behind would strand every voice on
160+
* whichever phases the trace happened to start with. Every declared phase sits
161+
* within a radian or two of its neighbours, well inside half a turn, so easing
162+
* straight to the target is also the shortest way round the circle.
138163
*/
139164
function easeSignature(current: MutableWave[], target: readonly IWave[]): void {
140165
for (let i = 0; i < current.length && i < target.length; i++) {
141166
current[i].frequency += (target[i].frequency - current[i].frequency) * SIGNATURE_EASING;
142167
current[i].amplitude += (target[i].amplitude - current[i].amplitude) * SIGNATURE_EASING;
143168
current[i].speed += (target[i].speed - current[i].speed) * SIGNATURE_EASING;
169+
current[i].phase += (target[i].phase - current[i].phase) * SIGNATURE_EASING;
144170
}
145171
}
146172

@@ -505,12 +531,14 @@ export class VoiceModeOnboardingBanner extends Disposable {
505531
this.domNode.setAttribute('role', 'region');
506532
this.domNode.setAttribute('aria-label', localize('voiceMode.onboarding.region', "Voice Mode introduction"));
507533

508-
// Voice Mode is live, but it should not be listening while the user is
509-
// still reading and picking a voice. Listening resumes on "Done" (or on
510-
// the mic button) once they have made their choice.
511-
if (this.voiceSessionController.voiceState.get() === 'listening') {
512-
this.voiceSessionController.stopListening();
513-
}
534+
// Voice Mode is live, but it must not be listening while the user is still
535+
// reading and picking a voice. A hold is used rather than `stopListening`
536+
// because the card goes up on `isConnecting`, before the session exists:
537+
// `stopListening` no-ops until connected, and hands-free would then open
538+
// the microphone on `session_init` with the card still on screen.
539+
// Released in `finish()`, which is the only way out of the card.
540+
this.voiceSessionController.setAutoListenHeld(true);
541+
this._register(toDisposable(() => this.voiceSessionController.setAutoListenHeld(false)));
514542

515543
const copy = dom.append(this.domNode, dom.$('.voice-mode-onboarding-copy'));
516544
const title = dom.append(copy, dom.$('.voice-mode-onboarding-title'));
@@ -533,10 +561,10 @@ export class VoiceModeOnboardingBanner extends Disposable {
533561

534562
/**
535563
* The signature the shared trace should be showing: the selected voice's, or
536-
* a calm blend of all of them before anything has been chosen.
564+
* {@link RESTING_SIGNATURE} before anything has been chosen.
537565
*/
538566
private currentSignature(): readonly IWave[] {
539-
return this.selectedVoice?.signature ?? VOICES[0].signature;
567+
return this.selectedVoice?.signature ?? RESTING_SIGNATURE;
540568
}
541569

542570
/** The single full-width trace the whole card shares. */
@@ -569,7 +597,7 @@ export class VoiceModeOnboardingBanner extends Disposable {
569597
// click, and "this is yours" after it.
570598
const icon = dom.append(option, dom.$('span.voice-mode-onboarding-voice-icon'));
571599
dom.append(icon, dom.$(`span.codicon.codicon-${Codicon.play.id}.voice-mode-onboarding-voice-idle`)).setAttribute('aria-hidden', 'true');
572-
dom.append(icon, dom.$(`span.codicon.codicon-${Codicon.check.id}.voice-mode-onboarding-voice-chosen`)).setAttribute('aria-hidden', 'true');
600+
dom.append(icon, dom.$(`span.codicon.codicon-${Codicon.checkCompact.id}.voice-mode-onboarding-voice-chosen`)).setAttribute('aria-hidden', 'true');
573601
const bars = dom.append(icon, dom.$('span.voice-mode-onboarding-voice-bars'));
574602
bars.setAttribute('aria-hidden', 'true');
575603
for (let bar = 0; bar < 3; bar++) {
@@ -664,7 +692,7 @@ export class VoiceModeOnboardingBanner extends Disposable {
664692
close.tabIndex = 0;
665693
close.setAttribute('role', 'button');
666694
close.setAttribute('aria-label', localize('voiceMode.onboarding.close', "Close the Voice Mode introduction"));
667-
dom.append(close, dom.$(`span.codicon.codicon-${Codicon.close.id}`)).setAttribute('aria-hidden', 'true');
695+
dom.append(close, dom.$(`span.codicon.codicon-${Codicon.closeCompact.id}`)).setAttribute('aria-hidden', 'true');
668696
this._register(dom.addDisposableListener(close, dom.EventType.CLICK, () => this.finish()));
669697
this._register(dom.addDisposableListener(close, dom.EventType.KEY_DOWN, event => {
670698
const keyboardEvent = new StandardKeyboardEvent(event);
@@ -721,13 +749,12 @@ export class VoiceModeOnboardingBanner extends Disposable {
721749
private finish(): void {
722750
this.player.stop();
723751

724-
if (this.selectedVoice !== undefined && this.configurationService.getValue<boolean>('agents.voice.handsFree') === true) {
725-
this.voiceSessionController.pttDown();
726-
this.voiceSessionController.pttUp();
727-
status(localize('voiceMode.onboarding.listening', "Voice Mode is listening."));
728-
} else {
729-
status(localize('voiceMode.onboarding.ready', "Voice Mode is ready. Press the mic button to start talking."));
730-
}
752+
// Releasing the hold is what hands the session back: hands-free picks up
753+
// and starts listening, push-to-talk stays quiet until the mic button.
754+
// The release itself runs on dispose, below.
755+
status(this.configurationService.getValue<boolean>('agents.voice.handsFree') === true
756+
? localize('voiceMode.onboarding.listening', "Voice Mode is listening.")
757+
: localize('voiceMode.onboarding.ready', "Voice Mode is ready. Press the mic button to start talking."));
731758

732759
this.options.onDismiss();
733760
}
@@ -745,8 +772,11 @@ export interface IVoiceModeOnboardingService {
745772
* @param container the element the banner is appended to.
746773
* @param focusRoot the element whose focus marks this host as the active one
747774
* (typically the chat input part the container lives in).
775+
* @param focus hands focus back to this host's input when the banner closes.
776+
* Passed explicitly because `focusRoot` is a container, not a control - the
777+
* host knows where its caret belongs and this service does not.
748778
*/
749-
registerHost(container: HTMLElement, focusRoot: HTMLElement): IDisposable;
779+
registerHost(container: HTMLElement, focusRoot: HTMLElement, focus: () => void): IDisposable;
750780

751781
/**
752782
* Show the introduction if the user has never seen it. Marks it as seen on
@@ -758,6 +788,7 @@ export interface IVoiceModeOnboardingService {
758788
interface IHost {
759789
readonly container: HTMLElement;
760790
readonly focusRoot: HTMLElement;
791+
readonly focus: () => void;
761792
lastFocused: number;
762793
}
763794

@@ -775,8 +806,8 @@ export class VoiceModeOnboardingService extends Disposable implements IVoiceMode
775806
super();
776807
}
777808

778-
registerHost(container: HTMLElement, focusRoot: HTMLElement): IDisposable {
779-
const host: IHost = { container, focusRoot, lastFocused: 0 };
809+
registerHost(container: HTMLElement, focusRoot: HTMLElement, focus: () => void): IDisposable {
810+
const host: IHost = { container, focusRoot, focus, lastFocused: 0 };
780811
this.hosts.add(host);
781812

782813
const store = new DisposableStore();
@@ -805,8 +836,6 @@ export class VoiceModeOnboardingService extends Disposable implements IVoiceMode
805836
return;
806837
}
807838

808-
this.storageService.store(AgentsVoiceStorageKeys.IntroBannerShown, true, StorageScope.APPLICATION, StorageTarget.USER);
809-
810839
// The host class is owned here and in `hide()` rather than by the card's
811840
// own disposer: a disposer runs when the *next* card replaces this one,
812841
// which would strip the class the new card had just added.
@@ -816,6 +845,10 @@ export class VoiceModeOnboardingService extends Disposable implements IVoiceMode
816845
});
817846
host.container.classList.add('has-voice-mode-onboarding');
818847
this.bannerHost = host;
848+
849+
// Only now is the one appearance actually spent. Storing any earlier
850+
// would burn it on a card that threw on the way up and was never seen.
851+
this.storageService.store(AgentsVoiceStorageKeys.IntroBannerShown, true, StorageScope.APPLICATION, StorageTarget.USER);
819852
}
820853

821854
/**
@@ -837,9 +870,20 @@ export class VoiceModeOnboardingService extends Disposable implements IVoiceMode
837870
}
838871

839872
private hide(): void {
840-
this.bannerHost?.container.classList.remove('has-voice-mode-onboarding');
873+
const host = this.bannerHost;
874+
// Whether focus comes back depends on where it is now: someone who
875+
// dismissed from the keyboard is standing inside the card and would
876+
// otherwise be dropped on the document body. Someone who clicked away
877+
// first should not have the caret yanked back to the input.
878+
const restoreFocus = !!host && dom.isAncestorOfActiveElement(host.container);
879+
880+
host?.container.classList.remove('has-voice-mode-onboarding');
841881
this.bannerHost = undefined;
842882
this.banner.clear();
883+
884+
if (restoreFocus) {
885+
host.focus();
886+
}
843887
}
844888
}
845889

0 commit comments

Comments
 (0)