diff --git a/crates/okena-ai/src/accounts.rs b/crates/okena-ai/src/accounts.rs index 1e895b129..0a1c20b41 100644 --- a/crates/okena-ai/src/accounts.rs +++ b/crates/okena-ai/src/accounts.rs @@ -302,7 +302,10 @@ impl Accounts { error: None, created_ms: now_ms(), }; - self.state.lock().logins.push(login); + let mut state = self.state.lock(); + // Starting the same login again replaces the one left pending. + state.logins.retain(|pending| pending.label != login.label); + state.logins.push(login); Ok(()) } diff --git a/crates/okena-ai/src/server.rs b/crates/okena-ai/src/server.rs index 9335b7bc2..461ee8bfd 100644 --- a/crates/okena-ai/src/server.rs +++ b/crates/okena-ai/src/server.rs @@ -913,6 +913,20 @@ mod tests { } } + #[tokio::test] + async fn starting_a_login_again_replaces_the_pending_one() { + let h = harness(vec![account("a", "token-a", "uuid-a")]).await; + let start = |label: &str| AiProxyCommand::StartLogin { + label: label.into(), + }; + let first = h.client.command(&start("a")).await.unwrap(); + h.client.command(&start("b")).await.unwrap(); + let status = h.client.command(&start(" a ")).await.unwrap(); + let labels: Vec<_> = status.logins.iter().map(|login| login.label.as_str()).collect(); + assert_eq!(labels, ["b", "a"]); + assert_ne!(status.logins[1].id, first.logins[0].id); + } + #[tokio::test] async fn all_accounts_limited_returns_the_provider_429() { let h = harness(vec![account("a", "token-a", "uuid-a")]).await; diff --git a/crates/okena-app/src/views/overlays/settings_panel/render_ai_proxy.rs b/crates/okena-app/src/views/overlays/settings_panel/render_ai_proxy.rs index c9961f694..9a60bf3c5 100644 --- a/crates/okena-app/src/views/overlays/settings_panel/render_ai_proxy.rs +++ b/crates/okena-app/src/views/overlays/settings_panel/render_ai_proxy.rs @@ -30,6 +30,9 @@ pub(super) enum AiProxyLoad { pub(super) struct AiProxyPanel { pub(super) load: AiProxyLoad, pub(super) busy: bool, + /// The account whose "Log In Again" started the pending login, so its + /// finish row shows under that account rather than under Add Account. + relogin_account: Option, label_input: Entity, code_input: Entity, } @@ -39,9 +42,14 @@ impl AiProxyPanel { Self { load: AiProxyLoad::NotLoaded, busy: false, + relogin_account: None, label_input: cx.new(|cx| SimpleInputState::new(cx).placeholder("e.g. Work Max")), + // The code is longer than the row: wrap it rather than run out of the box. code_input: cx.new(|cx| { - SimpleInputState::new(cx).placeholder("Paste the code shown after authorizing") + SimpleInputState::new(cx) + .multiline() + .submit_on_enter() + .placeholder("Paste the code shown after authorizing") }), } } @@ -57,6 +65,11 @@ impl SettingsPanel { })); return; }; + // A login the user just started opens in the browser right away. + let opens_login = match &request { + AiProxyCommand::StartLogin { label } => Some(label.trim().to_owned()), + _ => None, + }; self.ai_proxy.busy = true; if matches!(self.ai_proxy.load, AiProxyLoad::NotLoaded) { self.ai_proxy.load = AiProxyLoad::Loading; @@ -67,6 +80,14 @@ impl SettingsPanel { .background_executor() .spawn(async move { send_ai_proxy_command(&endpoint, request) }) .await; + if let Some(label) = opens_login + && let Some(login) = state + .status + .as_ref() + .and_then(|status| status.logins.iter().rfind(|login| login.label == label)) + { + okena_core::process::open_url(&login.authorize_url); + } let _ = this.update(cx, |this, cx| { this.ai_proxy.busy = false; this.ai_proxy.load = AiProxyLoad::Loaded(Box::new(state)); @@ -131,6 +152,22 @@ impl SettingsPanel { }; let busy = self.ai_proxy.busy; + // One login at a time: logging an account in again shows under that + // account, a new account's login under Add Account. + let login = state + .status + .as_ref() + .and_then(|status| status.logins.last().cloned()); + let relogin_of = login.as_ref().and_then(|login| { + let account_id = self.ai_proxy.relogin_account.as_ref()?; + state + .status + .as_ref()? + .accounts + .iter() + .find(|account| &account.id == account_id && account.label == login.label) + .map(|account| account.id.clone()) + }); let mut accounts = section_container(&t); match &state.status { Some(status) if status.accounts.is_empty() => { @@ -146,12 +183,20 @@ impl SettingsPanel { Some(status) => { let count = status.accounts.len(); for (index, account) in status.accounts.iter().enumerate() { + let has_border = index + 1 < count; + let relogin = login + .clone() + .filter(|_| relogin_of.as_deref() == Some(account.id.as_str())); accounts = accounts.child(self.render_ai_account( account, - index + 1 < count, + has_border && relogin.is_none(), busy, cx, )); + if let Some(login) = relogin { + accounts = accounts + .child(self.render_ai_login_finish(login, has_border, busy, cx)); + } } } None => {} @@ -207,15 +252,11 @@ impl SettingsPanel { .child(self.render_project_policies(status, busy, cx)); } - let login = state - .status - .as_ref() - .and_then(|status| status.logins.last().cloned()); content.child(section_header("Add Account", &t, cx)).child( - section_container(&t).child(match login { + section_container(&t).child(match login.filter(|_| relogin_of.is_none()) { None => self.render_ai_login_start(busy, cx).into_any_element(), Some(login) => self - .render_ai_login_finish(login, busy, cx) + .render_ai_login_finish(login, false, busy, cx) .into_any_element(), }), ) @@ -577,6 +618,7 @@ impl SettingsPanel { let enabled = account.enabled; let toggle_id = id.clone(); let relogin_label = account.label.clone(); + let relogin_id = id.clone(); settings_row_with_desc( format!("ai-account-{id}"), &title, @@ -593,6 +635,10 @@ impl SettingsPanel { row.child( button(format!("ai-account-relogin-{id}"), "Log In Again", &t).on_click( cx.listener(move |this, _, _, cx| { + if busy { + return; + } + this.ai_proxy.relogin_account = Some(relogin_id.clone()); this.ai_proxy_command( AiProxyCommand::StartLogin { label: relogin_label.clone(), @@ -652,6 +698,8 @@ impl SettingsPanel { .child( input_box(&t) .flex_1() + .min_w_0() + .overflow_hidden() .child(SimpleInput::new(&self.ai_proxy.label_input)), ) .child( @@ -661,6 +709,7 @@ impl SettingsPanel { if label.is_empty() || busy { return; } + this.ai_proxy.relogin_account = None; this.ai_proxy_command(AiProxyCommand::StartLogin { label }, cx); }, )), @@ -671,6 +720,7 @@ impl SettingsPanel { fn render_ai_login_finish( &self, login: okena_core::ai_proxy::AiPendingLogin, + has_border: bool, busy: bool, cx: &mut Context, ) -> impl IntoElement { @@ -683,10 +733,10 @@ impl SettingsPanel { settings_input_row( "ai-login-finish", &format!("Log in \u{201c}{}\u{201d}", login.label), - "Open the link, sign in with the account you want to add, then paste the code it shows.", + "Sign in on the page that opened (or open it again below), then paste the code it shows.", &t, cx, - false, + has_border, ) .child( div() @@ -706,7 +756,14 @@ impl SettingsPanel { div() .flex() .gap(px(6.0)) - .child(input_box(&t).flex_1().child(SimpleInput::new(&self.ai_proxy.code_input))) + .items_start() + .child( + input_box(&t) + .flex_1() + .min_w_0() + .overflow_hidden() + .child(SimpleInput::new(&self.ai_proxy.code_input)), + ) .child( button_primary( "ai-login-finish-button", @@ -719,7 +776,12 @@ impl SettingsPanel { return; } this.ai_proxy.code_input.update(cx, |input, cx| input.set_value("", cx)); - this.ai_proxy.label_input.update(cx, |input, cx| input.set_value("", cx)); + // A re-login leaves a name typed under Add Account alone. + if this.ai_proxy.relogin_account.is_none() { + this.ai_proxy + .label_input + .update(cx, |input, cx| input.set_value("", cx)); + } this.ai_proxy_command( AiProxyCommand::FinishLogin { login_id: finish_id.clone(),