@@ -34,6 +34,17 @@ public partial class ChatViewModel : ExtendedTool, IChatManagerService
3434 private readonly Dictionary < string , List < ChatSessionHistoryItem > > _historyByService = new ( StringComparer . Ordinal ) ;
3535
3636 private bool _initialized ;
37+
38+ // Single-flight guard: initialization is kicked off automatically when a service is
39+ // selected. A send that happens while that is still running must join the running
40+ // operation instead of starting a second (destructive) initialization.
41+ private Task < bool > ? _initializeTask ;
42+ private IChatService ? _initializeTaskService ;
43+
44+ // Completion sources waiting for the selected service to report a connected status.
45+ private readonly List < TaskCompletionSource < bool > > _connectionWaiters = [ ] ;
46+
47+ private static readonly TimeSpan ConnectionWaitTimeout = TimeSpan . FromMinutes ( 2 ) ;
3748 // FIFO of messages sent locally so the echoed ChatUserMessageEvent can be matched
3849 // (suppressed for normal/steered sends, or used to activate a queued message).
3950 private readonly Queue < PendingLocalMessage > _pendingLocalMessages = new ( ) ;
@@ -136,6 +147,41 @@ public bool IsConnected
136147 SteerCommand . NotifyCanExecuteChanged ( ) ;
137148 QueueCommand . NotifyCanExecuteChanged ( ) ;
138149 AbortCommand . NotifyCanExecuteChanged ( ) ;
150+
151+ if ( value ) ReleaseConnectionWaiters ( true ) ;
152+ }
153+ }
154+ }
155+
156+ /// <summary>
157+ /// True while a send is waiting for the selected service to finish connecting.
158+ /// </summary>
159+ public bool IsWaitingForConnection
160+ {
161+ get ;
162+ private set
163+ {
164+ if ( SetProperty ( ref field , value ) )
165+ {
166+ SendCommand . NotifyCanExecuteChanged ( ) ;
167+ SteerCommand . NotifyCanExecuteChanged ( ) ;
168+ QueueCommand . NotifyCanExecuteChanged ( ) ;
169+ }
170+ }
171+ }
172+
173+ /// <summary>
174+ /// True while the selected service is still initializing/starting up. Sending is allowed in
175+ /// this state; the message is held back until the connection is up.
176+ /// </summary>
177+ public bool IsConnecting
178+ {
179+ get ;
180+ private set
181+ {
182+ if ( SetProperty ( ref field , value ) )
183+ {
184+ SendCommand . NotifyCanExecuteChanged ( ) ;
139185 }
140186 }
141187 }
@@ -194,6 +240,7 @@ public IChatService? SelectedChatService
194240 oldValue . EventReceived -= OnEventReceived ;
195241 oldValue . StatusChanged -= OnStatusChanged ;
196242 oldValue . SessionReset -= OnSessionReset ;
243+ ReleaseConnectionWaiters ( false ) ;
197244 }
198245
199246 if ( value == null ) return ;
@@ -234,15 +281,88 @@ public override void InitializeContent()
234281
235282 private async Task < bool > InitializeCurrentAsync ( )
236283 {
237- if ( SelectedChatService == null ) return false ;
238-
239- var status = await SelectedChatService . InitializeAsync ( ) ;
284+ var service = SelectedChatService ;
285+ if ( service == null ) return false ;
286+
287+ // Join an initialization that is already running for this service instead of
288+ // starting a second one (a concurrent initialization tears down the client the
289+ // first one is still setting up).
290+ if ( _initializeTask is { } running && _initializeTaskService == service )
291+ {
292+ return await running ;
293+ }
294+
295+ var task = InitializeServiceAsync ( service ) ;
296+ _initializeTask = task ;
297+ _initializeTaskService = service ;
298+ IsConnecting = true ;
299+
300+ try
301+ {
302+ return await task ;
303+ }
304+ finally
305+ {
306+ if ( ReferenceEquals ( _initializeTask , task ) )
307+ {
308+ _initializeTask = null ;
309+ _initializeTaskService = null ;
310+ IsConnecting = false ;
311+ }
312+ }
313+ }
314+
315+ private async Task < bool > InitializeServiceAsync ( IChatService service )
316+ {
317+ var status = await service . InitializeAsync ( ) ;
318+
319+ if ( SelectedChatService != service ) return status ;
240320
241321 IsInitialized = status ;
242322
323+ if ( ! status ) ReleaseConnectionWaiters ( false ) ;
324+
243325 return status ;
244326 }
245327
328+ /// <summary>
329+ /// Waits until the selected service reports a connected status, instead of failing a send
330+ /// that arrives while the service is still starting up.
331+ /// </summary>
332+ private async Task < bool > WaitForConnectionAsync ( )
333+ {
334+ if ( IsConnected ) return true ;
335+
336+ var waiter = new TaskCompletionSource < bool > ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
337+ _connectionWaiters . Add ( waiter ) ;
338+
339+ IsWaitingForConnection = true ;
340+
341+ try
342+ {
343+ return await waiter . Task . WaitAsync ( ConnectionWaitTimeout ) ;
344+ }
345+ catch ( TimeoutException )
346+ {
347+ return false ;
348+ }
349+ finally
350+ {
351+ _connectionWaiters . Remove ( waiter ) ;
352+ IsWaitingForConnection = _connectionWaiters . Count > 0 ;
353+ }
354+ }
355+
356+ private void ReleaseConnectionWaiters ( bool connected )
357+ {
358+ if ( _connectionWaiters . Count == 0 ) return ;
359+
360+ foreach ( var waiter in _connectionWaiters . ToArray ( ) )
361+ {
362+ waiter . TrySetResult ( connected ) ;
363+ }
364+ }
365+
246366 private async Task InitializeAndRestoreCurrentServiceAsync ( IChatService chatService )
247367 {
248368 LoadMessagesForService ( chatService . Name ) ;
@@ -293,27 +413,40 @@ private async Task SendInternalAsync(ChatSendMode mode)
293413 var prompt = CurrentMessage . Trim ( ) ;
294414 if ( string . IsNullOrWhiteSpace ( prompt ) ) return ;
295415
296- if ( SelectedChatService == null )
416+ var chatService = SelectedChatService ;
417+ if ( chatService == null )
297418 {
298419 AddErrorMessage ( "No chat service selected." ) ;
299420 return ;
300421 }
301422
302- if ( ! IsInitialized )
423+ var initialized = IsInitialized ;
424+ if ( ! initialized )
303425 {
304- await InitializeCurrentAsync ( ) ;
426+ initialized = await InitializeCurrentAsync ( ) ;
305427 }
306428
307429 if ( ! IsConnected )
308430 {
309- // Replace (don't stack) the transient connection warning; it is removed
310- // again as soon as a message actually goes through.
311- if ( _notConnectedMessage != null ) Messages . Remove ( _notConnectedMessage ) ;
312- _notConnectedMessage = new ChatMessageErrorViewModel ( $ "{ SelectedChatService . Name } is not connected yet.") ;
313- AddMessage ( _notConnectedMessage ) ;
314- return ;
431+ // The service may still be starting up (typical right after IDE launch).
432+ // Wait for it to connect instead of rejecting the message.
433+ var connected = initialized && await WaitForConnectionAsync ( ) ;
434+
435+ if ( ! connected )
436+ {
437+ // Replace (don't stack) the transient connection warning; it is removed
438+ // again as soon as a message actually goes through.
439+ if ( _notConnectedMessage != null ) Messages . Remove ( _notConnectedMessage ) ;
440+ _notConnectedMessage =
441+ new ChatMessageErrorViewModel ( $ "{ chatService . Name } is not connected yet.") ;
442+ AddMessage ( _notConnectedMessage ) ;
443+ return ;
444+ }
315445 }
316446
447+ // The user may have switched services while we were waiting for the connection.
448+ if ( ! ReferenceEquals ( SelectedChatService , chatService ) ) return ;
449+
317450 if ( _notConnectedMessage != null )
318451 {
319452 Messages . Remove ( _notConnectedMessage ) ;
@@ -354,7 +487,7 @@ private async Task SendInternalAsync(ChatSendMode mode)
354487
355488 try
356489 {
357- await SelectedChatService . SendAsync ( prompt , mode ) ;
490+ await chatService . SendAsync ( prompt , mode ) ;
358491 }
359492 catch ( Exception ex )
360493 {
@@ -435,7 +568,8 @@ private async Task RemoveQueuedMessageAsync(ChatMessageUserViewModel? message)
435568 private bool CanRemoveQueuedMessage ( ChatMessageUserViewModel ? message ) =>
436569 message != null && ReferenceEquals ( QueuedMessages . LastOrDefault ( ) , message ) ;
437570
438- private bool CanSend ( ) => IsConnected && ! string . IsNullOrWhiteSpace ( CurrentMessage ) ;
571+ private bool CanSend ( ) => ( IsConnected || IsConnecting ) && ! IsWaitingForConnection &&
572+ ! string . IsNullOrWhiteSpace ( CurrentMessage ) ;
439573
440574 private bool CanAbort ( ) => IsConnected && IsBusy ;
441575
0 commit comments