From 136b3e9fed3d5c6a012097a1611992a3c6e0c1cf Mon Sep 17 00:00:00 2001 From: Jakub Date: Tue, 10 Nov 2026 11:02:44 +0100 Subject: [PATCH 1/4] fix(ai): replace a pending login started again under the same name Clicking Log In again for the same account stacked pending logins whose PKCE state nobody could finish any more. Co-Authored-By: Claude Opus 5.5 --- crates/okena-ai/src/accounts.rs | 5 ++++- crates/okena-ai/src/server.rs | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) 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; From d74af4bfc2c4092567b89e429379a7cf272640f5 Mon Sep 17 00:00:00 2001 From: Jakub Date: Tue, 10 Nov 2026 11:02:44 +0100 Subject: [PATCH 2/4] fix(app): show an account's re-login under it and open the login page Log In Again started a login whose finish row only rendered under Add Account at the bottom of the panel, below the policy editors, so the click looked like it did nothing. The row now renders under the account that was clicked (matched by id), the authorize page opens in the browser as soon as a login starts, and repeated clicks while a command is in flight are ignored. Co-Authored-By: Claude Opus 5.5 --- .../settings_panel/render_ai_proxy.rs | 69 ++++++++++++++++--- 1 file changed, 59 insertions(+), 10 deletions(-) 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..31779e40f 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,6 +42,7 @@ impl AiProxyPanel { Self { load: AiProxyLoad::NotLoaded, busy: false, + relogin_account: None, label_input: cx.new(|cx| SimpleInputState::new(cx).placeholder("e.g. Work Max")), code_input: cx.new(|cx| { SimpleInputState::new(cx).placeholder("Paste the code shown after authorizing") @@ -57,6 +61,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 +76,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 +148,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 +179,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 +248,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 +614,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 +631,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(), @@ -661,6 +703,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 +714,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 +727,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() @@ -719,7 +763,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(), From 40f1eee573c13997942fdc763e8689797eb1db07 Mon Sep 17 00:00:00 2001 From: Jakub Date: Tue, 10 Nov 2026 11:07:32 +0100 Subject: [PATCH 3/4] fix(app): keep AI proxy login inputs inside the settings row A pasted login code is longer than the row; without min_w_0 the flex input grew to the text's width and pushed Finish past the panel edge. Co-Authored-By: Claude Opus 5.5 --- .../src/views/overlays/settings_panel/render_ai_proxy.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 31779e40f..ff0079809 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 @@ -694,6 +694,7 @@ impl SettingsPanel { .child( input_box(&t) .flex_1() + .min_w_0() .child(SimpleInput::new(&self.ai_proxy.label_input)), ) .child( @@ -750,7 +751,12 @@ impl SettingsPanel { div() .flex() .gap(px(6.0)) - .child(input_box(&t).flex_1().child(SimpleInput::new(&self.ai_proxy.code_input))) + .child( + input_box(&t) + .flex_1() + .min_w_0() + .child(SimpleInput::new(&self.ai_proxy.code_input)), + ) .child( button_primary( "ai-login-finish-button", From e5071106ef5f3b77516698ace7f53697966d816d Mon Sep 17 00:00:00 2001 From: Jakub Date: Tue, 10 Nov 2026 11:10:48 +0100 Subject: [PATCH 4/4] fix(app): wrap a pasted AI proxy login code instead of painting past it A single-line SimpleInput neither scrolls nor clips, so a long code ran under the Finish and Cancel buttons. The code input now wraps (Enter still does not insert a newline) and both login inputs clip. Co-Authored-By: Claude Opus 5.5 --- .../src/views/overlays/settings_panel/render_ai_proxy.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 ff0079809..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 @@ -44,8 +44,12 @@ impl AiProxyPanel { 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") }), } } @@ -695,6 +699,7 @@ impl SettingsPanel { input_box(&t) .flex_1() .min_w_0() + .overflow_hidden() .child(SimpleInput::new(&self.ai_proxy.label_input)), ) .child( @@ -751,10 +756,12 @@ impl SettingsPanel { div() .flex() .gap(px(6.0)) + .items_start() .child( input_box(&t) .flex_1() .min_w_0() + .overflow_hidden() .child(SimpleInput::new(&self.ai_proxy.code_input)), ) .child(