@@ -7,6 +7,10 @@ import type { TranslationKey } from '../../../../i18n/types';
77import { getActiveQoderCliEdition , getQoderCliLoginCommand } from '../../../../qoder/config/cli-edition' ;
88import { getQoderModelOverride } from '../../../../qoder/config/settings' ;
99import type { QoderModelConfig } from '../../../../qoder/models/qoder-model-config' ;
10+ import type {
11+ QoderLoginController ,
12+ QoderLoginFailure ,
13+ } from '../../../../qoder/services/qoder-login-service' ;
1014import {
1115 CHECK_ICON ,
1216 CHEVRON_LEFT_ICON ,
@@ -43,6 +47,7 @@ export interface ToolbarCallbacks {
4347 getRuntimeStatus ?: ( ) => QoderRuntimeStatus ;
4448 retryRuntimeCatalog ?: ( ) => Promise < void > ;
4549 subscribeRuntimeStatus ?: ( listener : ( status : QoderRuntimeStatus ) => void ) => ( ) => void ;
50+ loginService ?: QoderLoginController ;
4651}
4752
4853const DEFAULT_RUNTIME_STATUS : QoderRuntimeStatus = {
@@ -68,6 +73,15 @@ function canUseCachedModels(status: QoderRuntimeStatus): boolean {
6873 return status . kind === 'checking' || status . kind === 'offline' || status . kind === 'failed' ;
6974}
7075
76+ function getSignInFailureMessage ( failure : QoderLoginFailure ) : string {
77+ switch ( failure . kind ) {
78+ case 'cliMissing' : return t ( 'chat.signIn.errorCliMissing' ) ;
79+ case 'nodeMissing' : return t ( 'chat.signIn.errorNodeMissing' ) ;
80+ case 'spawn' : return t ( 'chat.signIn.errorStartFailed' ) ;
81+ case 'process' : return t ( 'chat.signIn.errorProcessFailed' ) ;
82+ }
83+ }
84+
7185export class ModelSelector {
7286 private readonly container : HTMLElement ;
7387 private buttonEl : HTMLElement | null = null ;
@@ -77,6 +91,7 @@ export class ModelSelector {
7791 private popover : ClickPopover | null = null ;
7892 private editingModel : string | null = null ;
7993 private unsubscribeRuntimeStatus : ( ( ) => void ) | null = null ;
94+ private unsubscribeLoginState : ( ( ) => void ) | null = null ;
8095
8196 constructor ( parentEl : HTMLElement , private readonly callbacks : ToolbarCallbacks ) {
8297 this . container = parentEl . createDiv ( { cls : 'qoderian-model-selector' } ) ;
@@ -85,13 +100,18 @@ export class ModelSelector {
85100 this . updateDisplay ( ) ;
86101 this . renderOptions ( ) ;
87102 } ) ?? null ;
103+ this . unsubscribeLoginState = callbacks . loginService ?. subscribe ( ( ) => {
104+ this . renderOptions ( ) ;
105+ } ) ?? null ;
88106 }
89107
90108 destroy ( ) : void {
91109 this . popover ?. destroy ( ) ;
92110 this . popover = null ;
93111 this . unsubscribeRuntimeStatus ?.( ) ;
94112 this . unsubscribeRuntimeStatus = null ;
113+ this . unsubscribeLoginState ?.( ) ;
114+ this . unsubscribeLoginState = null ;
95115 }
96116
97117 private getAvailableModels ( ) {
@@ -177,15 +197,19 @@ export class ModelSelector {
177197 statusEl . createDiv ( { cls : 'qoderian-model-runtime-title' , text : getRuntimeStatusLabel ( status ) } ) ;
178198 statusEl . createDiv ( { cls : 'qoderian-model-runtime-message' , text : status . message } ) ;
179199 if ( status . kind === 'authRequired' ) {
180- statusEl . createEl ( 'code' , {
181- cls : 'qoderian-model-runtime-command' ,
182- text : getQoderCliLoginCommand ( getActiveQoderCliEdition ( ) ) ,
183- } ) ;
200+ this . renderSignInFlow ( statusEl ) ;
184201 }
185202 if ( status . details ) {
186203 statusEl . setAttribute ( 'title' , status . details ) ;
187204 }
188- if ( this . callbacks . retryRuntimeCatalog ) {
205+ const loginRunning = status . kind === 'authRequired'
206+ && ( this . callbacks . loginService ?. isRunning ( ) ?? false ) ;
207+ // When the in-app sign-in flow is available it owns authRequired
208+ // recovery (a successful sign-in refreshes the catalog), so the generic
209+ // Retry button would only add a redundant, misaligned second action.
210+ const signInOwnsAuth = status . kind === 'authRequired'
211+ && this . callbacks . loginService !== undefined ;
212+ if ( this . callbacks . retryRuntimeCatalog && ! loginRunning && ! signInOwnsAuth ) {
189213 const retryButton = statusEl . createEl ( 'button' , {
190214 cls : 'qoderian-model-runtime-retry' ,
191215 text : status . kind === 'checking' ? 'Checking…' : 'Retry' ,
@@ -295,6 +319,94 @@ export class ModelSelector {
295319 this . updateDropdownPlacement ( ) ;
296320 }
297321
322+ private renderSignInFlow ( statusEl : HTMLElement ) : void {
323+ const loginService = this . callbacks . loginService ;
324+ if ( ! loginService ) {
325+ statusEl . createEl ( 'code' , {
326+ cls : 'qoderian-model-runtime-command' ,
327+ text : getQoderCliLoginCommand ( getActiveQoderCliEdition ( ) ) ,
328+ } ) ;
329+ return ;
330+ }
331+
332+ const state = loginService . getState ( ) ;
333+
334+ if ( state . phase === 'waiting' ) {
335+ statusEl . createDiv ( {
336+ cls : 'qoderian-signin-waiting' ,
337+ text : t ( 'chat.signIn.waiting' ) ,
338+ } ) ;
339+ } else if ( state . phase === 'succeeded' ) {
340+ statusEl . createDiv ( {
341+ cls : 'qoderian-signin-waiting' ,
342+ text : t ( 'chat.signIn.verifying' ) ,
343+ } ) ;
344+ return ;
345+ } else if ( state . phase === 'failed' && state . failure ) {
346+ const errorEl = statusEl . createDiv ( {
347+ cls : 'qoderian-signin-error' ,
348+ text : getSignInFailureMessage ( state . failure ) ,
349+ } ) ;
350+ if ( state . failure . details ) {
351+ errorEl . setAttribute ( 'title' , state . failure . details ) ;
352+ }
353+ }
354+
355+ const actionsEl = statusEl . createDiv ( { cls : 'qoderian-signin-actions' } ) ;
356+
357+ if ( state . phase === 'waiting' ) {
358+ if ( state . authUrl ) {
359+ const openButton = actionsEl . createEl ( 'button' , {
360+ cls : 'qoderian-signin-open mod-cta' ,
361+ text : t ( 'chat.signIn.openLink' ) ,
362+ } ) ;
363+ openButton . addEventListener ( 'click' , ( event ) => {
364+ event . stopPropagation ( ) ;
365+ loginService . openAuthUrl ( ) ;
366+ } ) ;
367+ const copyButton = actionsEl . createEl ( 'button' , {
368+ cls : 'qoderian-signin-copy' ,
369+ text : t ( 'chat.signIn.copyLink' ) ,
370+ } ) ;
371+ copyButton . addEventListener ( 'click' , ( event ) => {
372+ event . stopPropagation ( ) ;
373+ void this . copyAuthUrl ( state . authUrl as string ) ;
374+ } ) ;
375+ }
376+ const cancelButton = actionsEl . createEl ( 'button' , {
377+ cls : 'qoderian-signin-cancel' ,
378+ text : t ( 'common.cancel' ) ,
379+ } ) ;
380+ cancelButton . addEventListener ( 'click' , ( event ) => {
381+ event . stopPropagation ( ) ;
382+ loginService . cancel ( ) ;
383+ } ) ;
384+ return ;
385+ }
386+
387+ const signInButton = actionsEl . createEl ( 'button' , {
388+ cls : 'qoderian-signin-button mod-cta' ,
389+ text : state . phase === 'failed' ? t ( 'chat.signIn.retry' ) : t ( 'chat.signIn.button' ) ,
390+ } ) ;
391+ if ( state . phase === 'starting' ) {
392+ signInButton . setAttribute ( 'disabled' , 'true' ) ;
393+ signInButton . setText ( t ( 'chat.signIn.starting' ) ) ;
394+ }
395+ signInButton . addEventListener ( 'click' , ( event ) => {
396+ event . stopPropagation ( ) ;
397+ loginService . start ( ) ;
398+ } ) ;
399+ }
400+
401+ private async copyAuthUrl ( authUrl : string ) : Promise < void > {
402+ try {
403+ await navigator . clipboard . writeText ( authUrl ) ;
404+ new Notice ( t ( 'chat.signIn.copied' ) ) ;
405+ } catch {
406+ new Notice ( t ( 'chat.signIn.copyFailed' ) ) ;
407+ }
408+ }
409+
298410 /**
299411 * Places the dropdown against the real viewport: stay flush with the
300412 * trigger (inline-start) and flip only when the panel would overflow,
0 commit comments