@@ -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 */
139164function 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 {
758788interface 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