From 3d67953bc358fe928261462525e8085f5df7e3ce Mon Sep 17 00:00:00 2001 From: Snix Date: Thu, 29 Jan 2026 11:10:20 +0100 Subject: [PATCH 1/7] feat: add account switching --- src-tauri/src/bin/codex_monitor_daemon.rs | 225 ++++++++++++++- src-tauri/src/codex.rs | 264 +++++++++++++++++- src-tauri/src/lib.rs | 2 + src/App.tsx | 34 ++- src/features/app/components/Sidebar.tsx | 28 +- src/features/app/components/SidebarFooter.tsx | 24 ++ src/features/layout/hooks/useLayoutNodes.tsx | 7 + .../hooks/useThreadAccountInfo.test.tsx | 45 +++ .../threads/hooks/useThreadAccountInfo.ts | 108 +++++++ .../hooks/useThreads.integration.test.tsx | 1 + src/features/threads/hooks/useThreads.ts | 12 +- .../threads/hooks/useThreadsReducer.ts | 16 ++ src/services/tauri.ts | 8 + src/styles/sidebar.css | 65 +++++ src/types.ts | 7 + 15 files changed, 841 insertions(+), 5 deletions(-) create mode 100644 src/features/threads/hooks/useThreadAccountInfo.test.tsx create mode 100644 src/features/threads/hooks/useThreadAccountInfo.ts diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index a659483ba..484beb55a 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -23,6 +23,7 @@ mod utils; #[path = "../types.rs"] mod types; +use base64::Engine; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use std::collections::HashMap; @@ -31,17 +32,22 @@ use std::fs::File; use std::io::Read; use std::net::SocketAddr; use std::path::PathBuf; +use std::process::Stdio; use std::sync::Arc; +use std::time::Duration; use ignore::WalkBuilder; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{TcpListener, TcpStream}; use tokio::process::Command; use tokio::sync::{broadcast, mpsc, Mutex}; +use tokio::time::timeout; use uuid::Uuid; use utils::{git_env_path, resolve_git_binary}; -use backend::app_server::{spawn_workspace_session, WorkspaceSession}; +use backend::app_server::{ + build_codex_command_with_bin, spawn_workspace_session, WorkspaceSession, +}; use backend::events::{AppServerEvent, EventSink, TerminalOutput}; use storage::{read_settings, read_workspaces, write_settings, write_workspaces}; use types::{ @@ -1302,6 +1308,83 @@ impl DaemonState { .await } + async fn account_read(&self, workspace_id: String) -> Result { + let response = match self.get_session(&workspace_id).await { + Ok(session) => session.send_request("account/read", Value::Null).await.ok(), + Err(_) => None, + }; + let codex_home = self.resolve_codex_home_for_workspace(&workspace_id).await.ok(); + let fallback = read_auth_account(codex_home); + Ok(build_account_response(response, fallback)) + } + + async fn codex_login(&self, workspace_id: String) -> Result { + let (entry, parent_entry, settings) = { + let workspaces = self.workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .ok_or("workspace not found")? + .clone(); + let parent_entry = entry + .parent_id + .as_ref() + .and_then(|parent_id| workspaces.get(parent_id)) + .cloned(); + let settings = self.app_settings.lock().await.clone(); + (entry, parent_entry, settings) + }; + + let codex_bin = entry + .codex_bin + .clone() + .filter(|value| !value.trim().is_empty()) + .or(settings.codex_bin.clone()); + let codex_args = + codex_args::resolve_workspace_codex_args(&entry, parent_entry.as_ref(), Some(&settings)); + let codex_home = codex_home::resolve_workspace_codex_home(&entry, parent_entry.as_ref()) + .or_else(codex_home::resolve_default_codex_home); + + let mut command = build_codex_command_with_bin(codex_bin); + if let Some(ref codex_home) = codex_home { + command.env("CODEX_HOME", codex_home); + } + codex_args::apply_codex_args(&mut command, codex_args.as_deref())?; + command.arg("login"); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + + let output = match timeout(Duration::from_secs(120), command.output()).await { + Ok(result) => result.map_err(|error| error.to_string())?, + Err(_) => return Err("Codex login timed out.".to_string()), + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + let combined = if stdout.trim().is_empty() { + stderr.trim().to_string() + } else if stderr.trim().is_empty() { + stdout.trim().to_string() + } else { + format!("{}\n{}", stdout.trim(), stderr.trim()) + }; + let limited = combined.chars().take(4000).collect::(); + + if !output.status.success() { + return Err(if detail.is_empty() { + "Codex login failed.".to_string() + } else { + format!("Codex login failed: {detail}") + }); + } + + Ok(json!({ "output": limited })) + } + async fn skills_list(&self, workspace_id: String) -> Result { let session = self.get_session(&workspace_id).await?; let params = json!({ @@ -1394,6 +1477,138 @@ fn normalize_git_path(path: &str) -> String { path.replace('\\', "/") } +struct AuthAccount { + email: Option, + plan_type: Option, +} + +fn build_account_response(response: Option, fallback: Option) -> Value { + let mut account = response + .as_ref() + .and_then(extract_account_map) + .unwrap_or_default(); + if let Some(fallback) = fallback { + if !account.contains_key("email") { + if let Some(email) = fallback.email { + account.insert("email".to_string(), Value::String(email)); + } + } + if !account.contains_key("planType") { + if let Some(plan) = fallback.plan_type { + account.insert("planType".to_string(), Value::String(plan)); + } + } + if !account.contains_key("type") { + account.insert("type".to_string(), Value::String("chatgpt".to_string())); + } + } + + let account_value = if account.is_empty() { + Value::Null + } else { + Value::Object(account) + }; + let mut result = Map::new(); + result.insert("account".to_string(), account_value); + if let Some(requires_openai_auth) = response + .as_ref() + .and_then(extract_requires_openai_auth) + { + result.insert( + "requiresOpenaiAuth".to_string(), + Value::Bool(requires_openai_auth), + ); + } + Value::Object(result) +} + +fn extract_account_map(value: &Value) -> Option> { + let account = value + .get("account") + .or_else(|| value.get("result").and_then(|result| result.get("account"))) + .and_then(|value| value.as_object().cloned()); + if account.is_some() { + return account; + } + let root = value.as_object()?; + if root.contains_key("email") || root.contains_key("planType") || root.contains_key("type") { + return Some(root.clone()); + } + None +} + +fn extract_requires_openai_auth(value: &Value) -> Option { + value + .get("requiresOpenaiAuth") + .or_else(|| value.get("requires_openai_auth")) + .or_else(|| { + value + .get("result") + .and_then(|result| result.get("requiresOpenaiAuth")) + }) + .or_else(|| { + value + .get("result") + .and_then(|result| result.get("requires_openai_auth")) + }) + .and_then(|value| value.as_bool()) +} + +fn read_auth_account(codex_home: Option) -> Option { + let codex_home = codex_home?; + let auth_path = codex_home.join("auth.json"); + let data = std::fs::read(auth_path).ok()?; + let auth_value: Value = serde_json::from_slice(&data).ok()?; + let tokens = auth_value.get("tokens")?; + let id_token = tokens + .get("idToken") + .or_else(|| tokens.get("id_token")) + .and_then(|value| value.as_str())?; + let payload = decode_jwt_payload(id_token)?; + + let auth_dict = payload + .get("https://api.openai.com/auth") + .and_then(|value| value.as_object()); + let profile_dict = payload + .get("https://api.openai.com/profile") + .and_then(|value| value.as_object()); + let plan = normalize_string( + auth_dict + .and_then(|dict| dict.get("chatgpt_plan_type")) + .or_else(|| payload.get("chatgpt_plan_type")), + ); + let email = normalize_string( + payload + .get("email") + .or_else(|| profile_dict.and_then(|dict| dict.get("email"))), + ); + + if email.is_none() && plan.is_none() { + return None; + } + + Some(AuthAccount { + email, + plan_type: plan, + }) +} + +fn decode_jwt_payload(token: &str) -> Option { + let payload = token.split('.').nth(1)?; + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload.as_bytes()) + .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload.as_bytes())) + .ok()?; + serde_json::from_slice(&decoded).ok() +} + +fn normalize_string(value: Option<&Value>) -> Option { + value + .and_then(|value| value.as_str()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + fn list_workspace_files_inner(root: &PathBuf, max_files: usize) -> Vec { let mut results = Vec::new(); let walker = WalkBuilder::new(root) @@ -2158,6 +2373,14 @@ async fn handle_rpc_request( let workspace_id = parse_string(¶ms, "workspaceId")?; state.account_rate_limits(workspace_id).await } + "account_read" => { + let workspace_id = parse_string(¶ms, "workspaceId")?; + state.account_read(workspace_id).await + } + "codex_login" => { + let workspace_id = parse_string(¶ms, "workspaceId")?; + state.codex_login(workspace_id).await + } "skills_list" => { let workspace_id = parse_string(¶ms, "workspaceId")?; state.skills_list(workspace_id).await diff --git a/src-tauri/src/codex.rs b/src-tauri/src/codex.rs index 6faebd0ca..f086d1607 100644 --- a/src-tauri/src/codex.rs +++ b/src-tauri/src/codex.rs @@ -1,6 +1,9 @@ +use base64::Engine; use serde_json::{json, Map, Value}; +use std::fs; use std::io::ErrorKind; use std::path::PathBuf; +use std::process::Stdio; use std::sync::Arc; use std::time::Duration; @@ -14,7 +17,7 @@ use crate::backend::app_server::{ build_codex_command_with_bin, build_codex_path_env, check_codex_installation, spawn_workspace_session as spawn_workspace_session_inner, }; -use crate::codex_args::apply_codex_args; +use crate::codex_args::{apply_codex_args, resolve_workspace_codex_args}; use crate::codex_config; use crate::codex_home::{resolve_default_codex_home, resolve_workspace_codex_home}; use crate::event_sink::TauriEventSink; @@ -503,6 +506,133 @@ pub(crate) async fn account_rate_limits( .await } +#[tauri::command] +pub(crate) async fn account_read( + workspace_id: String, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + if remote_backend::is_remote_mode(&*state).await { + return remote_backend::call_remote( + &*state, + app, + "account_read", + json!({ "workspaceId": workspace_id }), + ) + .await; + } + + let session = { + let sessions = state.sessions.lock().await; + sessions.get(&workspace_id).cloned() + }; + let response = if let Some(session) = session { + session.send_request("account/read", Value::Null).await.ok() + } else { + None + }; + + let (entry, parent_entry) = { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .ok_or("workspace not found")? + .clone(); + let parent_entry = entry + .parent_id + .as_ref() + .and_then(|parent_id| workspaces.get(parent_id)) + .cloned(); + (entry, parent_entry) + }; + let codex_home = resolve_workspace_codex_home(&entry, parent_entry.as_ref()) + .or_else(resolve_default_codex_home); + let fallback = read_auth_account(codex_home); + + Ok(build_account_response(response, fallback)) +} + +#[tauri::command] +pub(crate) async fn codex_login( + workspace_id: String, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + if remote_backend::is_remote_mode(&*state).await { + return remote_backend::call_remote( + &*state, + app, + "codex_login", + json!({ "workspaceId": workspace_id }), + ) + .await; + } + + let (entry, parent_entry, settings) = { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .ok_or("workspace not found")? + .clone(); + let parent_entry = entry + .parent_id + .as_ref() + .and_then(|parent_id| workspaces.get(parent_id)) + .cloned(); + let settings = state.app_settings.lock().await.clone(); + (entry, parent_entry, settings) + }; + + let codex_bin = entry + .codex_bin + .clone() + .filter(|value| !value.trim().is_empty()) + .or(settings.codex_bin.clone()); + let codex_args = resolve_workspace_codex_args(&entry, parent_entry.as_ref(), Some(&settings)); + let codex_home = resolve_workspace_codex_home(&entry, parent_entry.as_ref()) + .or_else(resolve_default_codex_home); + + let mut command = build_codex_command_with_bin(codex_bin); + if let Some(ref codex_home) = codex_home { + command.env("CODEX_HOME", codex_home); + } + apply_codex_args(&mut command, codex_args.as_deref())?; + command.arg("login"); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + + let output = match timeout(Duration::from_secs(120), command.output()).await { + Ok(result) => result.map_err(|error| error.to_string())?, + Err(_) => return Err("Codex login timed out.".to_string()), + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + let combined = if stdout.trim().is_empty() { + stderr.trim().to_string() + } else if stderr.trim().is_empty() { + stdout.trim().to_string() + } else { + format!("{}\n{}", stdout.trim(), stderr.trim()) + }; + let limited = combined.chars().take(4000).collect::(); + + if !output.status.success() { + return Err(if detail.is_empty() { + "Codex login failed.".to_string() + } else { + format!("Codex login failed: {detail}") + }); + } + + Ok(json!({ "output": limited })) +} + #[tauri::command] pub(crate) async fn skills_list( workspace_id: String, @@ -1048,3 +1178,135 @@ fn sanitize_run_worktree_name(value: &str) -> String { } format!("feat/{}", cleaned.trim_start_matches('/')) } + +struct AuthAccount { + email: Option, + plan_type: Option, +} + +fn build_account_response(response: Option, fallback: Option) -> Value { + let mut account = response + .as_ref() + .and_then(extract_account_map) + .unwrap_or_default(); + if let Some(fallback) = fallback { + if !account.contains_key("email") { + if let Some(email) = fallback.email { + account.insert("email".to_string(), Value::String(email)); + } + } + if !account.contains_key("planType") { + if let Some(plan) = fallback.plan_type { + account.insert("planType".to_string(), Value::String(plan)); + } + } + if !account.contains_key("type") { + account.insert("type".to_string(), Value::String("chatgpt".to_string())); + } + } + + let account_value = if account.is_empty() { + Value::Null + } else { + Value::Object(account) + }; + let mut result = Map::new(); + result.insert("account".to_string(), account_value); + if let Some(requires_openai_auth) = response + .as_ref() + .and_then(extract_requires_openai_auth) + { + result.insert( + "requiresOpenaiAuth".to_string(), + Value::Bool(requires_openai_auth), + ); + } + Value::Object(result) +} + +fn extract_account_map(value: &Value) -> Option> { + let account = value + .get("account") + .or_else(|| value.get("result").and_then(|result| result.get("account"))) + .and_then(|value| value.as_object().cloned()); + if account.is_some() { + return account; + } + let root = value.as_object()?; + if root.contains_key("email") || root.contains_key("planType") || root.contains_key("type") { + return Some(root.clone()); + } + None +} + +fn extract_requires_openai_auth(value: &Value) -> Option { + value + .get("requiresOpenaiAuth") + .or_else(|| value.get("requires_openai_auth")) + .or_else(|| { + value + .get("result") + .and_then(|result| result.get("requiresOpenaiAuth")) + }) + .or_else(|| { + value + .get("result") + .and_then(|result| result.get("requires_openai_auth")) + }) + .and_then(|value| value.as_bool()) +} + +fn read_auth_account(codex_home: Option) -> Option { + let codex_home = codex_home?; + let auth_path = codex_home.join("auth.json"); + let data = fs::read(auth_path).ok()?; + let auth_value: Value = serde_json::from_slice(&data).ok()?; + let tokens = auth_value.get("tokens")?; + let id_token = tokens + .get("idToken") + .or_else(|| tokens.get("id_token")) + .and_then(|value| value.as_str())?; + let payload = decode_jwt_payload(id_token)?; + + let auth_dict = payload + .get("https://api.openai.com/auth") + .and_then(|value| value.as_object()); + let profile_dict = payload + .get("https://api.openai.com/profile") + .and_then(|value| value.as_object()); + let plan = normalize_string( + auth_dict + .and_then(|dict| dict.get("chatgpt_plan_type")) + .or_else(|| payload.get("chatgpt_plan_type")), + ); + let email = normalize_string( + payload + .get("email") + .or_else(|| profile_dict.and_then(|dict| dict.get("email"))), + ); + + if email.is_none() && plan.is_none() { + return None; + } + + Some(AuthAccount { + email, + plan_type: plan, + }) +} + +fn decode_jwt_payload(token: &str) -> Option { + let payload = token.split('.').nth(1)?; + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload.as_bytes()) + .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload.as_bytes())) + .ok()?; + serde_json::from_slice(&decoded).ok() +} + +fn normalize_string(value: Option<&Value>) -> Option { + value + .and_then(|value| value.as_str()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f7df80679..f2363d5f3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -143,6 +143,8 @@ pub fn run() { git::create_git_branch, codex::model_list, codex::account_rate_limits, + codex::account_read, + codex::codex_login, codex::skills_list, prompts::prompts_list, prompts::prompts_create, diff --git a/src/App.tsx b/src/App.tsx index 3cbbe2df7..ecf45c0aa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -95,7 +95,7 @@ import { useGitCommitController } from "./features/app/hooks/useGitCommitControl import { WorkspaceHome } from "./features/workspaces/components/WorkspaceHome"; import { useWorkspaceHome } from "./features/workspaces/hooks/useWorkspaceHome"; import { useWorkspaceAgentMd } from "./features/workspaces/hooks/useWorkspaceAgentMd"; -import { pickWorkspacePath } from "./services/tauri"; +import { pickWorkspacePath, runCodexLogin } from "./services/tauri"; import type { AccessMode, ComposerEditorSettings, @@ -594,6 +594,7 @@ function MainApp() { threadListCursorByWorkspace, tokenUsageByThread, rateLimitsByWorkspace, + accountByWorkspace, planByThread, lastAgentMessageByThread, interruptTurn, @@ -614,6 +615,8 @@ function MainApp() { handleApprovalDecision, handleApprovalRemember, handleUserInputSubmit, + refreshAccountInfo, + refreshAccountRateLimits, } = useThreads({ activeWorkspace, onWorkspaceConnected: markWorkspaceConnected, @@ -626,6 +629,10 @@ function MainApp() { customPrompts: prompts, onMessageActivity: queueGitStatusRefresh }); + const [accountSwitching, setAccountSwitching] = useState(false); + const activeAccount = activeWorkspaceId + ? accountByWorkspace[activeWorkspaceId] ?? null + : null; const activeThreadIdRef = useRef(activeThreadId ?? null); const { getThreadRows } = useThreadRows(threadParentById); useEffect(() => { @@ -1106,6 +1113,28 @@ function MainApp() { [activeWorkspace, connectWorkspace, sendUserMessageToThread, startThreadForWorkspace], ); + const handleSwitchAccount = useCallback(async () => { + if (!activeWorkspaceId || accountSwitching) { + return; + } + setAccountSwitching(true); + try { + await runCodexLogin(activeWorkspaceId); + await refreshAccountInfo(activeWorkspaceId); + await refreshAccountRateLimits(activeWorkspaceId); + } catch (error) { + alertError(error); + } finally { + setAccountSwitching(false); + } + }, [ + activeWorkspaceId, + accountSwitching, + refreshAccountInfo, + refreshAccountRateLimits, + alertError, + ]); + const handleCreatePrompt = useCallback( async (data: { @@ -1529,6 +1558,9 @@ function MainApp() { activeThreadId, activeItems, activeRateLimits, + accountInfo: activeAccount, + onSwitchAccount: handleSwitchAccount, + accountSwitching, codeBlockCopyUseModifier: appSettings.composerCodeBlockCopyUseModifier, openAppTargets: appSettings.openAppTargets, openAppIconById, diff --git a/src/features/app/components/Sidebar.tsx b/src/features/app/components/Sidebar.tsx index c3e6d0aed..299313b0e 100644 --- a/src/features/app/components/Sidebar.tsx +++ b/src/features/app/components/Sidebar.tsx @@ -1,4 +1,9 @@ -import type { RateLimitSnapshot, ThreadSummary, WorkspaceInfo } from "../../../types"; +import type { + AccountSnapshot, + RateLimitSnapshot, + ThreadSummary, + WorkspaceInfo, +} from "../../../types"; import { createPortal } from "react-dom"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { RefObject } from "react"; @@ -47,6 +52,9 @@ type SidebarProps = { activeWorkspaceId: string | null; activeThreadId: string | null; accountRateLimits: RateLimitSnapshot | null; + accountInfo: AccountSnapshot | null; + onSwitchAccount: () => void; + accountSwitching: boolean; onOpenSettings: () => void; onOpenDebug: () => void; showDebugButton: boolean; @@ -93,6 +101,9 @@ export function Sidebar({ activeWorkspaceId, activeThreadId, accountRateLimits, + accountInfo, + onSwitchAccount, + accountSwitching, onOpenSettings, onOpenDebug, showDebugButton, @@ -205,6 +216,16 @@ export function Sidebar({ [normalizedQuery], ); + const accountEmail = accountInfo?.email?.trim() ?? ""; + const accountButtonLabel = accountEmail + ? accountEmail + : accountInfo?.type === "apikey" + ? "API key" + : "Sign in to Codex"; + const accountActionLabel = accountEmail ? "Switch account" : "Sign in"; + const showAccountSwitcher = Boolean(activeWorkspaceId); + const accountSwitchDisabled = accountSwitching || !activeWorkspaceId; + const pinnedThreadRows = (() => { type ThreadRow = { thread: ThreadSummary; depth: number }; const groups: Array<{ @@ -607,6 +628,11 @@ export function Sidebar({ weeklyResetLabel={weeklyResetLabel} creditsLabel={creditsLabel} showWeekly={showWeekly} + showAccountSwitcher={showAccountSwitcher} + accountLabel={accountButtonLabel} + accountActionLabel={accountActionLabel} + accountDisabled={accountSwitchDisabled} + onSwitchAccount={onSwitchAccount} /> void; }; export function SidebarFooter({ @@ -14,6 +19,11 @@ export function SidebarFooter({ weeklyResetLabel, creditsLabel, showWeekly, + showAccountSwitcher, + accountLabel, + accountActionLabel, + accountDisabled, + onSwitchAccount, }: SidebarFooterProps) { return (
@@ -60,6 +70,20 @@ export function SidebarFooter({ )}
{creditsLabel &&
{creditsLabel}
} + {showAccountSwitcher && ( + + )} ); } diff --git a/src/features/layout/hooks/useLayoutNodes.tsx b/src/features/layout/hooks/useLayoutNodes.tsx index 7bfeaaa8b..239b5598d 100644 --- a/src/features/layout/hooks/useLayoutNodes.tsx +++ b/src/features/layout/hooks/useLayoutNodes.tsx @@ -26,6 +26,7 @@ import type { ConversationItem, ComposerEditorSettings, CustomPromptOption, + AccountSnapshot, DebugEntry, DictationSessionState, DictationTranscript, @@ -109,6 +110,9 @@ type LayoutNodesOptions = { activeThreadId: string | null; activeItems: ConversationItem[]; activeRateLimits: RateLimitSnapshot | null; + accountInfo: AccountSnapshot | null; + onSwitchAccount: () => void; + accountSwitching: boolean; codeBlockCopyUseModifier: boolean; openAppTargets: OpenAppTarget[]; openAppIconById: Record; @@ -435,6 +439,9 @@ export function useLayoutNodes(options: LayoutNodesOptions): LayoutNodesResult { activeWorkspaceId={options.activeWorkspaceId} activeThreadId={options.activeThreadId} accountRateLimits={options.activeRateLimits} + accountInfo={options.accountInfo} + onSwitchAccount={options.onSwitchAccount} + accountSwitching={options.accountSwitching} onOpenSettings={options.onOpenSettings} onOpenDebug={options.onOpenDebug} showDebugButton={options.showDebugButton} diff --git a/src/features/threads/hooks/useThreadAccountInfo.test.tsx b/src/features/threads/hooks/useThreadAccountInfo.test.tsx new file mode 100644 index 000000000..353dda9af --- /dev/null +++ b/src/features/threads/hooks/useThreadAccountInfo.test.tsx @@ -0,0 +1,45 @@ +// @vitest-environment jsdom +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { getAccountInfo } from "../../../services/tauri"; +import { useThreadAccountInfo } from "./useThreadAccountInfo"; + +vi.mock("../../../services/tauri", () => ({ + getAccountInfo: vi.fn(), +})); + +describe("useThreadAccountInfo", () => { + it("refreshes account info on connect and dispatches snapshot", async () => { + vi.mocked(getAccountInfo).mockResolvedValue({ + result: { + account: { type: "chatgpt", email: "user@example.com", planType: "pro" }, + requiresOpenaiAuth: false, + }, + }); + + const dispatch = vi.fn(); + + renderHook(() => + useThreadAccountInfo({ + activeWorkspaceId: "ws-1", + activeWorkspaceConnected: true, + dispatch, + }), + ); + + await waitFor(() => { + expect(getAccountInfo).toHaveBeenCalledWith("ws-1"); + }); + + expect(dispatch).toHaveBeenCalledWith({ + type: "setAccountInfo", + workspaceId: "ws-1", + account: { + type: "chatgpt", + email: "user@example.com", + planType: "pro", + requiresOpenaiAuth: false, + }, + }); + }); +}); diff --git a/src/features/threads/hooks/useThreadAccountInfo.ts b/src/features/threads/hooks/useThreadAccountInfo.ts new file mode 100644 index 000000000..02dc08772 --- /dev/null +++ b/src/features/threads/hooks/useThreadAccountInfo.ts @@ -0,0 +1,108 @@ +import { useCallback, useEffect } from "react"; +import type { AccountSnapshot, DebugEntry } from "../../../types"; +import { getAccountInfo } from "../../../services/tauri"; +import type { ThreadAction } from "./useThreadsReducer"; + +type UseThreadAccountInfoOptions = { + activeWorkspaceId: string | null; + activeWorkspaceConnected?: boolean; + dispatch: React.Dispatch; + onDebug?: (entry: DebugEntry) => void; +}; + +function normalizeAccountSnapshot( + response: Record | null, +): AccountSnapshot { + const accountValue = + (response?.result as Record | undefined)?.account ?? + response?.account; + const account = + accountValue && typeof accountValue === "object" + ? (accountValue as Record) + : null; + const requiresOpenaiAuthRaw = + (response?.result as Record | undefined)?.requiresOpenaiAuth ?? + (response?.result as Record | undefined)?.requires_openai_auth ?? + response?.requiresOpenaiAuth ?? + response?.requires_openai_auth; + const requiresOpenaiAuth = + typeof requiresOpenaiAuthRaw === "boolean" ? requiresOpenaiAuthRaw : null; + + if (!account) { + return { + type: "unknown", + email: null, + planType: null, + requiresOpenaiAuth, + }; + } + + const typeRaw = + typeof account.type === "string" ? account.type.toLowerCase() : "unknown"; + const type = typeRaw === "chatgpt" || typeRaw === "apikey" ? typeRaw : "unknown"; + const emailRaw = typeof account.email === "string" ? account.email.trim() : ""; + const planRaw = + typeof account.planType === "string" ? account.planType.trim() : ""; + + return { + type, + email: emailRaw ? emailRaw : null, + planType: planRaw ? planRaw : null, + requiresOpenaiAuth, + }; +} + +export function useThreadAccountInfo({ + activeWorkspaceId, + activeWorkspaceConnected, + dispatch, + onDebug, +}: UseThreadAccountInfoOptions) { + const refreshAccountInfo = useCallback( + async (workspaceId?: string) => { + const targetId = workspaceId ?? activeWorkspaceId; + if (!targetId) { + return; + } + onDebug?.({ + id: `${Date.now()}-client-account-read`, + timestamp: Date.now(), + source: "client", + label: "account/read", + payload: { workspaceId: targetId }, + }); + try { + const response = await getAccountInfo(targetId); + onDebug?.({ + id: `${Date.now()}-server-account-read`, + timestamp: Date.now(), + source: "server", + label: "account/read response", + payload: response, + }); + dispatch({ + type: "setAccountInfo", + workspaceId: targetId, + account: normalizeAccountSnapshot(response), + }); + } catch (error) { + onDebug?.({ + id: `${Date.now()}-client-account-read-error`, + timestamp: Date.now(), + source: "error", + label: "account/read error", + payload: error instanceof Error ? error.message : String(error), + }); + } + }, + [activeWorkspaceId, dispatch, onDebug], + ); + + useEffect(() => { + if (activeWorkspaceConnected && activeWorkspaceId) { + void refreshAccountInfo(activeWorkspaceId); + } + }, [activeWorkspaceConnected, activeWorkspaceId, refreshAccountInfo]); + + return { refreshAccountInfo }; +} diff --git a/src/features/threads/hooks/useThreads.integration.test.tsx b/src/features/threads/hooks/useThreads.integration.test.tsx index 00883a1ba..c03e15543 100644 --- a/src/features/threads/hooks/useThreads.integration.test.tsx +++ b/src/features/threads/hooks/useThreads.integration.test.tsx @@ -32,6 +32,7 @@ vi.mock("../../../services/tauri", () => ({ resumeThread: vi.fn(), archiveThread: vi.fn(), getAccountRateLimits: vi.fn(), + getAccountInfo: vi.fn(), interruptTurn: vi.fn(), })); diff --git a/src/features/threads/hooks/useThreads.ts b/src/features/threads/hooks/useThreads.ts index baa897e89..e1a7b80fb 100644 --- a/src/features/threads/hooks/useThreads.ts +++ b/src/features/threads/hooks/useThreads.ts @@ -9,6 +9,7 @@ import { useThreadEventHandlers } from "./useThreadEventHandlers"; import { useThreadActions } from "./useThreadActions"; import { useThreadMessaging } from "./useThreadMessaging"; import { useThreadApprovals } from "./useThreadApprovals"; +import { useThreadAccountInfo } from "./useThreadAccountInfo"; import { useThreadRateLimits } from "./useThreadRateLimits"; import { useThreadSelectors } from "./useThreadSelectors"; import { useThreadStatus } from "./useThreadStatus"; @@ -73,6 +74,12 @@ export function useThreads({ dispatch, onDebug, }); + const { refreshAccountInfo } = useThreadAccountInfo({ + activeWorkspaceId, + activeWorkspaceConnected: activeWorkspace?.connected, + dispatch, + onDebug, + }); const { markProcessing, markReviewing, setActiveTurnId } = useThreadStatus({ dispatch, @@ -109,8 +116,9 @@ export function useThreads({ (workspaceId: string) => { onWorkspaceConnected(workspaceId); void refreshAccountRateLimits(workspaceId); + void refreshAccountInfo(workspaceId); }, - [onWorkspaceConnected, refreshAccountRateLimits], + [onWorkspaceConnected, refreshAccountRateLimits, refreshAccountInfo], ); const handlers = useThreadEventHandlers({ @@ -265,9 +273,11 @@ export function useThreads({ activeTurnIdByThread: state.activeTurnIdByThread, tokenUsageByThread: state.tokenUsageByThread, rateLimitsByWorkspace: state.rateLimitsByWorkspace, + accountByWorkspace: state.accountByWorkspace, planByThread: state.planByThread, lastAgentMessageByThread: state.lastAgentMessageByThread, refreshAccountRateLimits, + refreshAccountInfo, interruptTurn, removeThread, pinThread, diff --git a/src/features/threads/hooks/useThreadsReducer.ts b/src/features/threads/hooks/useThreadsReducer.ts index 8272de701..44880d726 100644 --- a/src/features/threads/hooks/useThreadsReducer.ts +++ b/src/features/threads/hooks/useThreadsReducer.ts @@ -1,4 +1,5 @@ import type { + AccountSnapshot, ApprovalRequest, ConversationItem, RateLimitSnapshot, @@ -129,6 +130,7 @@ export type ThreadState = { userInputRequests: RequestUserInputRequest[]; tokenUsageByThread: Record; rateLimitsByWorkspace: Record; + accountByWorkspace: Record; planByThread: Record; lastAgentMessageByThread: Record; }; @@ -226,6 +228,11 @@ export type ThreadAction = workspaceId: string; rateLimits: RateLimitSnapshot | null; } + | { + type: "setAccountInfo"; + workspaceId: string; + account: AccountSnapshot | null; + } | { type: "setActiveTurnId"; threadId: string; turnId: string | null } | { type: "setThreadPlan"; threadId: string; plan: TurnPlan | null } | { type: "clearThreadPlan"; threadId: string } @@ -252,6 +259,7 @@ export const initialState: ThreadState = { userInputRequests: [], tokenUsageByThread: {}, rateLimitsByWorkspace: {}, + accountByWorkspace: {}, planByThread: {}, lastAgentMessageByThread: {}, }; @@ -977,6 +985,14 @@ export function threadReducer(state: ThreadState, action: ThreadAction): ThreadS [action.workspaceId]: action.rateLimits, }, }; + case "setAccountInfo": + return { + ...state, + accountByWorkspace: { + ...state.accountByWorkspace, + [action.workspaceId]: action.account, + }, + }; case "setThreadPlan": return { ...state, diff --git a/src/services/tauri.ts b/src/services/tauri.ts index fcfda2c04..a6aeab777 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -465,6 +465,14 @@ export async function getAccountRateLimits(workspaceId: string) { return invoke("account_rate_limits", { workspaceId }); } +export async function getAccountInfo(workspaceId: string) { + return invoke("account_read", { workspaceId }); +} + +export async function runCodexLogin(workspaceId: string) { + return invoke<{ output: string }>("codex_login", { workspaceId }); +} + export async function getSkillsList(workspaceId: string) { return invoke("skills_list", { workspaceId }); } diff --git a/src/styles/sidebar.css b/src/styles/sidebar.css index 4f583c416..adfb377a7 100644 --- a/src/styles/sidebar.css +++ b/src/styles/sidebar.css @@ -1072,6 +1072,71 @@ color: var(--text-subtle); } +.sidebar-account-switcher { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 10px; + border-radius: 10px; + border: 1px solid var(--border-muted); + background: var(--surface-item); + color: var(--text-stronger); + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; +} + +.sidebar-account-switcher:hover { + background: var(--surface-hover); + border-color: var(--border-strong); + color: var(--text-strong); +} + +.sidebar-account-switcher:disabled { + cursor: default; + opacity: 0.6; + background: var(--surface-item); + border-color: var(--border-muted); + color: var(--text-muted); +} + +.account-switcher-text { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + min-width: 0; + flex: 1; + overflow: hidden; +} + +.account-switcher-label { + color: var(--text-subtle); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.account-switcher-email { + color: var(--text-stronger); + font-weight: 600; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-switcher-action { + color: var(--text-subtle); + font-size: 10px; + letter-spacing: 0.04em; + text-transform: uppercase; + flex-shrink: 0; + white-space: nowrap; +} + .sidebar-corner-actions { position: absolute; left: var(--sidebar-padding); diff --git a/src/types.ts b/src/types.ts index 01809cb7c..90efe7759 100644 --- a/src/types.ts +++ b/src/types.ts @@ -387,6 +387,13 @@ export type RateLimitSnapshot = { planType: string | null; }; +export type AccountSnapshot = { + type: "chatgpt" | "apikey" | "unknown"; + email: string | null; + planType: string | null; + requiresOpenaiAuth: boolean | null; +}; + export type QueuedMessage = { id: string; text: string; From 2f486339b2a2c1e503d508d4dae8d82341f72d8a Mon Sep 17 00:00:00 2001 From: Snix Date: Thu, 29 Jan 2026 11:17:49 +0100 Subject: [PATCH 2/7] test: update sidebar test props --- src/features/app/components/Sidebar.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/features/app/components/Sidebar.test.tsx b/src/features/app/components/Sidebar.test.tsx index 05d833866..50d602d20 100644 --- a/src/features/app/components/Sidebar.test.tsx +++ b/src/features/app/components/Sidebar.test.tsx @@ -19,6 +19,10 @@ const baseProps = { activeWorkspaceId: null, activeThreadId: null, accountRateLimits: null, + accountInfo: null, + usageShowRemaining: false, + onSwitchAccount: vi.fn(), + accountSwitching: false, onOpenSettings: vi.fn(), onOpenDebug: vi.fn(), showDebugButton: false, From b6ef95c9dc0b59d081e83737fb69618876c9ab75 Mon Sep 17 00:00:00 2001 From: Snix Date: Thu, 29 Jan 2026 12:52:34 +0100 Subject: [PATCH 3/7] feat: move account switcher to popover --- src/features/app/components/Sidebar.tsx | 10 +-- .../app/components/SidebarCornerActions.tsx | 68 +++++++++++++++++++ src/features/app/components/SidebarFooter.tsx | 24 ------- src/styles/sidebar.css | 65 ++++++------------ 4 files changed, 92 insertions(+), 75 deletions(-) diff --git a/src/features/app/components/Sidebar.tsx b/src/features/app/components/Sidebar.tsx index 299313b0e..bd3383d91 100644 --- a/src/features/app/components/Sidebar.tsx +++ b/src/features/app/components/Sidebar.tsx @@ -628,16 +628,16 @@ export function Sidebar({ weeklyResetLabel={weeklyResetLabel} creditsLabel={creditsLabel} showWeekly={showWeekly} - showAccountSwitcher={showAccountSwitcher} - accountLabel={accountButtonLabel} - accountActionLabel={accountActionLabel} - accountDisabled={accountSwitchDisabled} - onSwitchAccount={onSwitchAccount} /> ); diff --git a/src/features/app/components/SidebarCornerActions.tsx b/src/features/app/components/SidebarCornerActions.tsx index 9680604ba..9b5e4d4fe 100644 --- a/src/features/app/components/SidebarCornerActions.tsx +++ b/src/features/app/components/SidebarCornerActions.tsx @@ -1,19 +1,87 @@ import ScrollText from "lucide-react/dist/esm/icons/scroll-text"; import Settings from "lucide-react/dist/esm/icons/settings"; +import User from "lucide-react/dist/esm/icons/user"; +import { useEffect, useRef, useState } from "react"; type SidebarCornerActionsProps = { onOpenSettings: () => void; onOpenDebug: () => void; showDebugButton: boolean; + showAccountSwitcher: boolean; + accountLabel: string; + accountActionLabel: string; + accountDisabled: boolean; + onSwitchAccount: () => void; }; export function SidebarCornerActions({ onOpenSettings, onOpenDebug, showDebugButton, + showAccountSwitcher, + accountLabel, + accountActionLabel, + accountDisabled, + onSwitchAccount, }: SidebarCornerActionsProps) { + const [accountMenuOpen, setAccountMenuOpen] = useState(false); + const accountMenuRef = useRef(null); + + useEffect(() => { + if (!accountMenuOpen) { + return; + } + const handleClick = (event: MouseEvent) => { + const target = event.target as Node; + if (accountMenuRef.current?.contains(target)) { + return; + } + setAccountMenuOpen(false); + }; + window.addEventListener("mousedown", handleClick); + return () => { + window.removeEventListener("mousedown", handleClick); + }; + }, [accountMenuOpen]); + + useEffect(() => { + if (!showAccountSwitcher) { + setAccountMenuOpen(false); + } + }, [showAccountSwitcher]); + return (
+ {showAccountSwitcher && ( +
+ + {accountMenuOpen && ( +
+
Account
+
{accountLabel}
+ +
+ )} +
+ )} - )}
); } diff --git a/src/styles/sidebar.css b/src/styles/sidebar.css index adfb377a7..e18f35b08 100644 --- a/src/styles/sidebar.css +++ b/src/styles/sidebar.css @@ -1072,69 +1072,42 @@ color: var(--text-subtle); } -.sidebar-account-switcher { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 8px 10px; - border-radius: 10px; - border: 1px solid var(--border-muted); - background: var(--surface-item); - color: var(--text-stronger); - font-size: 11px; - font-weight: 600; - cursor: pointer; - transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; -} - -.sidebar-account-switcher:hover { - background: var(--surface-hover); - border-color: var(--border-strong); - color: var(--text-strong); -} - -.sidebar-account-switcher:disabled { - cursor: default; - opacity: 0.6; - background: var(--surface-item); - border-color: var(--border-muted); - color: var(--text-muted); +.sidebar-account-menu { + position: relative; } -.account-switcher-text { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 2px; - min-width: 0; - flex: 1; - overflow: hidden; +.sidebar-account-popover { + position: absolute; + left: 0; + bottom: 42px; + min-width: 220px; + padding: 10px 12px; + display: grid; + gap: 8px; + z-index: 10; } -.account-switcher-label { +.sidebar-account-title { color: var(--text-subtle); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; } -.account-switcher-email { +.sidebar-account-value { color: var(--text-stronger); font-weight: 600; - max-width: 100%; + font-size: 12px; + max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.account-switcher-action { - color: var(--text-subtle); - font-size: 10px; - letter-spacing: 0.04em; - text-transform: uppercase; - flex-shrink: 0; - white-space: nowrap; +.sidebar-account-action { + width: 100%; + justify-content: center; + font-size: 11px; } .sidebar-corner-actions { From a68cd27f455d6213d294648def7fbab975f22168 Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Thu, 29 Jan 2026 13:29:37 +0100 Subject: [PATCH 4/7] fix: kill codex login on timeout --- src-tauri/src/bin/codex_monitor_daemon.rs | 44 +++++++++++++++++++---- src-tauri/src/codex.rs | 43 +++++++++++++++++++--- 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 484beb55a..707654216 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -37,7 +37,7 @@ use std::sync::Arc; use std::time::Duration; use ignore::WalkBuilder; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::net::{TcpListener, TcpStream}; use tokio::process::Command; use tokio::sync::{broadcast, mpsc, Mutex}; @@ -1353,13 +1353,45 @@ impl DaemonState { command.stdout(Stdio::piped()); command.stderr(Stdio::piped()); - let output = match timeout(Duration::from_secs(120), command.output()).await { + let mut child = command.spawn().map_err(|error| error.to_string())?; + let stdout_pipe = child.stdout.take(); + let stderr_pipe = child.stderr.take(); + + let stdout_task = tokio::spawn(async move { + let mut buffer = Vec::new(); + if let Some(mut stdout) = stdout_pipe { + let _ = stdout.read_to_end(&mut buffer).await; + } + buffer + }); + let stderr_task = tokio::spawn(async move { + let mut buffer = Vec::new(); + if let Some(mut stderr) = stderr_pipe { + let _ = stderr.read_to_end(&mut buffer).await; + } + buffer + }); + + let status = match timeout(Duration::from_secs(120), child.wait()).await { Ok(result) => result.map_err(|error| error.to_string())?, - Err(_) => return Err("Codex login timed out.".to_string()), + Err(_) => { + let _ = child.kill().await; + let _ = child.wait().await; + return Err("Codex login timed out.".to_string()); + } }; - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); + let stdout_bytes = match stdout_task.await { + Ok(bytes) => bytes, + Err(_) => Vec::new(), + }; + let stderr_bytes = match stderr_task.await { + Ok(bytes) => bytes, + Err(_) => Vec::new(), + }; + + let stdout = String::from_utf8_lossy(&stdout_bytes); + let stderr = String::from_utf8_lossy(&stderr_bytes); let detail = if stderr.trim().is_empty() { stdout.trim() } else { @@ -1374,7 +1406,7 @@ impl DaemonState { }; let limited = combined.chars().take(4000).collect::(); - if !output.status.success() { + if !status.success() { return Err(if detail.is_empty() { "Codex login failed.".to_string() } else { diff --git a/src-tauri/src/codex.rs b/src-tauri/src/codex.rs index f086d1607..919805c67 100644 --- a/src-tauri/src/codex.rs +++ b/src-tauri/src/codex.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use std::time::Duration; use tauri::{AppHandle, State}; +use tokio::io::AsyncReadExt; use tokio::process::Command; use tokio::sync::mpsc; use tokio::time::timeout; @@ -601,13 +602,45 @@ pub(crate) async fn codex_login( command.stdout(Stdio::piped()); command.stderr(Stdio::piped()); - let output = match timeout(Duration::from_secs(120), command.output()).await { + let mut child = command.spawn().map_err(|error| error.to_string())?; + let stdout_pipe = child.stdout.take(); + let stderr_pipe = child.stderr.take(); + + let stdout_task = tokio::spawn(async move { + let mut buffer = Vec::new(); + if let Some(mut stdout) = stdout_pipe { + let _ = stdout.read_to_end(&mut buffer).await; + } + buffer + }); + let stderr_task = tokio::spawn(async move { + let mut buffer = Vec::new(); + if let Some(mut stderr) = stderr_pipe { + let _ = stderr.read_to_end(&mut buffer).await; + } + buffer + }); + + let status = match timeout(Duration::from_secs(120), child.wait()).await { Ok(result) => result.map_err(|error| error.to_string())?, - Err(_) => return Err("Codex login timed out.".to_string()), + Err(_) => { + let _ = child.kill().await; + let _ = child.wait().await; + return Err("Codex login timed out.".to_string()); + } + }; + + let stdout_bytes = match stdout_task.await { + Ok(bytes) => bytes, + Err(_) => Vec::new(), + }; + let stderr_bytes = match stderr_task.await { + Ok(bytes) => bytes, + Err(_) => Vec::new(), }; - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&stdout_bytes); + let stderr = String::from_utf8_lossy(&stderr_bytes); let detail = if stderr.trim().is_empty() { stdout.trim() } else { @@ -622,7 +655,7 @@ pub(crate) async fn codex_login( }; let limited = combined.chars().take(4000).collect::(); - if !output.status.success() { + if !status.success() { return Err(if detail.is_empty() { "Codex login failed.".to_string() } else { From d884161aacea7ab8ef02868f6fc42a15a9be07eb Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Thu, 29 Jan 2026 13:36:01 +0100 Subject: [PATCH 5/7] test: cover account fallback gating --- src-tauri/src/bin/codex_monitor_daemon.rs | 95 ++++++++++++++++++++--- src-tauri/src/codex.rs | 95 ++++++++++++++++++++--- 2 files changed, 170 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 707654216..ca82f2a60 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -1520,18 +1520,26 @@ fn build_account_response(response: Option, fallback: Option .and_then(extract_account_map) .unwrap_or_default(); if let Some(fallback) = fallback { - if !account.contains_key("email") { - if let Some(email) = fallback.email { - account.insert("email".to_string(), Value::String(email)); + let account_type = account + .get("type") + .and_then(|value| value.as_str()) + .map(|value| value.to_ascii_lowercase()); + let allow_fallback = account.is_empty() + || matches!(account_type.as_deref(), None | Some("chatgpt") | Some("unknown")); + if allow_fallback { + if !account.contains_key("email") { + if let Some(email) = fallback.email { + account.insert("email".to_string(), Value::String(email)); + } } - } - if !account.contains_key("planType") { - if let Some(plan) = fallback.plan_type { - account.insert("planType".to_string(), Value::String(plan)); + if !account.contains_key("planType") { + if let Some(plan) = fallback.plan_type { + account.insert("planType".to_string(), Value::String(plan)); + } + } + if !account.contains_key("type") { + account.insert("type".to_string(), Value::String("chatgpt".to_string())); } - } - if !account.contains_key("type") { - account.insert("type".to_string(), Value::String("chatgpt".to_string())); } } @@ -1625,6 +1633,73 @@ fn read_auth_account(codex_home: Option) -> Option { }) } +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn fallback_account() -> AuthAccount { + AuthAccount { + email: Some("chatgpt@example.com".to_string()), + plan_type: Some("plus".to_string()), + } + } + + fn result_account_map(value: &Value) -> Map { + value + .get("account") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default() + } + + #[test] + fn build_account_response_does_not_fallback_for_apikey() { + let response = Some(json!({ + "account": { + "type": "apikey" + } + })); + let result = build_account_response(response, Some(fallback_account())); + let account = result_account_map(&result); + + assert_eq!(account.get("type").and_then(Value::as_str), Some("apikey")); + assert!(!account.contains_key("email")); + assert!(!account.contains_key("planType")); + } + + #[test] + fn build_account_response_falls_back_when_account_missing() { + let result = build_account_response(None, Some(fallback_account())); + let account = result_account_map(&result); + + assert_eq!( + account.get("email").and_then(Value::as_str), + Some("chatgpt@example.com"), + ); + assert_eq!(account.get("planType").and_then(Value::as_str), Some("plus")); + assert_eq!(account.get("type").and_then(Value::as_str), Some("chatgpt")); + } + + #[test] + fn build_account_response_allows_fallback_for_chatgpt_type() { + let response = Some(json!({ + "account": { + "type": "chatgpt" + } + })); + let result = build_account_response(response, Some(fallback_account())); + let account = result_account_map(&result); + + assert_eq!(account.get("type").and_then(Value::as_str), Some("chatgpt")); + assert_eq!( + account.get("email").and_then(Value::as_str), + Some("chatgpt@example.com"), + ); + assert_eq!(account.get("planType").and_then(Value::as_str), Some("plus")); + } +} + fn decode_jwt_payload(token: &str) -> Option { let payload = token.split('.').nth(1)?; let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD diff --git a/src-tauri/src/codex.rs b/src-tauri/src/codex.rs index 919805c67..2e464b530 100644 --- a/src-tauri/src/codex.rs +++ b/src-tauri/src/codex.rs @@ -1223,18 +1223,26 @@ fn build_account_response(response: Option, fallback: Option .and_then(extract_account_map) .unwrap_or_default(); if let Some(fallback) = fallback { - if !account.contains_key("email") { - if let Some(email) = fallback.email { - account.insert("email".to_string(), Value::String(email)); + let account_type = account + .get("type") + .and_then(|value| value.as_str()) + .map(|value| value.to_ascii_lowercase()); + let allow_fallback = account.is_empty() + || matches!(account_type.as_deref(), None | Some("chatgpt") | Some("unknown")); + if allow_fallback { + if !account.contains_key("email") { + if let Some(email) = fallback.email { + account.insert("email".to_string(), Value::String(email)); + } } - } - if !account.contains_key("planType") { - if let Some(plan) = fallback.plan_type { - account.insert("planType".to_string(), Value::String(plan)); + if !account.contains_key("planType") { + if let Some(plan) = fallback.plan_type { + account.insert("planType".to_string(), Value::String(plan)); + } + } + if !account.contains_key("type") { + account.insert("type".to_string(), Value::String("chatgpt".to_string())); } - } - if !account.contains_key("type") { - account.insert("type".to_string(), Value::String("chatgpt".to_string())); } } @@ -1328,6 +1336,73 @@ fn read_auth_account(codex_home: Option) -> Option { }) } +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn fallback_account() -> AuthAccount { + AuthAccount { + email: Some("chatgpt@example.com".to_string()), + plan_type: Some("plus".to_string()), + } + } + + fn result_account_map(value: &Value) -> Map { + value + .get("account") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default() + } + + #[test] + fn build_account_response_does_not_fallback_for_apikey() { + let response = Some(json!({ + "account": { + "type": "apikey" + } + })); + let result = build_account_response(response, Some(fallback_account())); + let account = result_account_map(&result); + + assert_eq!(account.get("type").and_then(Value::as_str), Some("apikey")); + assert!(!account.contains_key("email")); + assert!(!account.contains_key("planType")); + } + + #[test] + fn build_account_response_falls_back_when_account_missing() { + let result = build_account_response(None, Some(fallback_account())); + let account = result_account_map(&result); + + assert_eq!( + account.get("email").and_then(Value::as_str), + Some("chatgpt@example.com"), + ); + assert_eq!(account.get("planType").and_then(Value::as_str), Some("plus")); + assert_eq!(account.get("type").and_then(Value::as_str), Some("chatgpt")); + } + + #[test] + fn build_account_response_allows_fallback_for_chatgpt_type() { + let response = Some(json!({ + "account": { + "type": "chatgpt" + } + })); + let result = build_account_response(response, Some(fallback_account())); + let account = result_account_map(&result); + + assert_eq!(account.get("type").and_then(Value::as_str), Some("chatgpt")); + assert_eq!( + account.get("email").and_then(Value::as_str), + Some("chatgpt@example.com"), + ); + assert_eq!(account.get("planType").and_then(Value::as_str), Some("plus")); + } +} + fn decode_jwt_payload(token: &str) -> Option { let payload = token.split('.').nth(1)?; let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD From 38aa456a14a3dd412da1215ba76a4a7a8fd9278b Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Thu, 29 Jan 2026 13:49:07 +0100 Subject: [PATCH 6/7] Add cancel support for Codex account login flow Implements cancellation for the Codex login process in both backend (Rust) and frontend (React/TypeScript). Adds a new Tauri command and RPC handler for canceling login, updates UI components to allow users to cancel account switching, and provides visual feedback during the process. --- src-tauri/src/bin/codex_monitor_daemon.rs | 67 +++++++++++++++- src-tauri/src/codex.rs | 76 ++++++++++++++++++- src-tauri/src/lib.rs | 1 + src-tauri/src/state.rs | 3 + src/App.tsx | 38 +++++++++- src/features/app/components/Sidebar.test.tsx | 1 + src/features/app/components/Sidebar.tsx | 6 ++ .../app/components/SidebarCornerActions.tsx | 46 ++++++++--- src/features/layout/hooks/useLayoutNodes.tsx | 2 + src/services/tauri.ts | 4 + src/styles/sidebar.css | 45 +++++++++++ 11 files changed, 275 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index ca82f2a60..bcb148ac3 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -34,13 +34,14 @@ use std::net::SocketAddr; use std::path::PathBuf; use std::process::Stdio; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use ignore::WalkBuilder; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::net::{TcpListener, TcpStream}; use tokio::process::Command; -use tokio::sync::{broadcast, mpsc, Mutex}; +use tokio::sync::{broadcast, mpsc, oneshot, Mutex}; use tokio::time::timeout; use uuid::Uuid; use utils::{git_env_path, resolve_git_binary}; @@ -109,6 +110,7 @@ struct DaemonState { settings_path: PathBuf, app_settings: Mutex, event_sink: DaemonEventSink, + codex_login_cancels: Mutex>>, } #[derive(Serialize, Deserialize)] @@ -131,6 +133,7 @@ impl DaemonState { settings_path, app_settings: Mutex::new(app_settings), event_sink, + codex_login_cancels: Mutex::new(HashMap::new()), } } @@ -1354,6 +1357,35 @@ impl DaemonState { command.stderr(Stdio::piped()); let mut child = command.spawn().map_err(|error| error.to_string())?; + let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); + { + let mut cancels = self.codex_login_cancels.lock().await; + if let Some(existing) = cancels.remove(&workspace_id) { + let _ = existing.send(()); + } + cancels.insert(workspace_id.clone(), cancel_tx); + } + let pid = child.id(); + let canceled = Arc::new(AtomicBool::new(false)); + let canceled_for_task = Arc::clone(&canceled); + let cancel_task = tokio::spawn(async move { + if cancel_rx.await.is_ok() { + canceled_for_task.store(true, Ordering::Relaxed); + if let Some(pid) = pid { + #[cfg(not(target_os = "windows"))] + unsafe { + libc::kill(pid as i32, libc::SIGKILL); + } + #[cfg(target_os = "windows")] + { + let _ = Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .status() + .await; + } + } + } + }); let stdout_pipe = child.stdout.take(); let stderr_pipe = child.stderr.take(); @@ -1377,10 +1409,25 @@ impl DaemonState { Err(_) => { let _ = child.kill().await; let _ = child.wait().await; + cancel_task.abort(); + { + let mut cancels = self.codex_login_cancels.lock().await; + cancels.remove(&workspace_id); + } return Err("Codex login timed out.".to_string()); } }; + cancel_task.abort(); + { + let mut cancels = self.codex_login_cancels.lock().await; + cancels.remove(&workspace_id); + } + + if canceled.load(Ordering::Relaxed) { + return Err("Codex login canceled.".to_string()); + } + let stdout_bytes = match stdout_task.await { Ok(bytes) => bytes, Err(_) => Vec::new(), @@ -1417,6 +1464,20 @@ impl DaemonState { Ok(json!({ "output": limited })) } + async fn codex_login_cancel(&self, workspace_id: String) -> Result { + let cancel_tx = { + let mut cancels = self.codex_login_cancels.lock().await; + cancels.remove(&workspace_id) + }; + let canceled = if let Some(tx) = cancel_tx { + let _ = tx.send(()); + true + } else { + false + }; + Ok(json!({ "canceled": canceled })) + } + async fn skills_list(&self, workspace_id: String) -> Result { let session = self.get_session(&workspace_id).await?; let params = json!({ @@ -2488,6 +2549,10 @@ async fn handle_rpc_request( let workspace_id = parse_string(¶ms, "workspaceId")?; state.codex_login(workspace_id).await } + "codex_login_cancel" => { + let workspace_id = parse_string(¶ms, "workspaceId")?; + state.codex_login_cancel(workspace_id).await + } "skills_list" => { let workspace_id = parse_string(¶ms, "workspaceId")?; state.skills_list(workspace_id).await diff --git a/src-tauri/src/codex.rs b/src-tauri/src/codex.rs index 2e464b530..d7c8614e6 100644 --- a/src-tauri/src/codex.rs +++ b/src-tauri/src/codex.rs @@ -5,12 +5,13 @@ use std::io::ErrorKind; use std::path::PathBuf; use std::process::Stdio; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use tauri::{AppHandle, State}; use tokio::io::AsyncReadExt; use tokio::process::Command; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, oneshot}; use tokio::time::timeout; pub(crate) use crate::backend::app_server::WorkspaceSession; @@ -603,6 +604,35 @@ pub(crate) async fn codex_login( command.stderr(Stdio::piped()); let mut child = command.spawn().map_err(|error| error.to_string())?; + let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); + { + let mut cancels = state.codex_login_cancels.lock().await; + if let Some(existing) = cancels.remove(&workspace_id) { + let _ = existing.send(()); + } + cancels.insert(workspace_id.clone(), cancel_tx); + } + let pid = child.id(); + let canceled = Arc::new(AtomicBool::new(false)); + let canceled_for_task = Arc::clone(&canceled); + let cancel_task = tokio::spawn(async move { + if cancel_rx.await.is_ok() { + canceled_for_task.store(true, Ordering::Relaxed); + if let Some(pid) = pid { + #[cfg(not(target_os = "windows"))] + unsafe { + libc::kill(pid as i32, libc::SIGKILL); + } + #[cfg(target_os = "windows")] + { + let _ = Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .status() + .await; + } + } + } + }); let stdout_pipe = child.stdout.take(); let stderr_pipe = child.stderr.take(); @@ -626,10 +656,25 @@ pub(crate) async fn codex_login( Err(_) => { let _ = child.kill().await; let _ = child.wait().await; + cancel_task.abort(); + { + let mut cancels = state.codex_login_cancels.lock().await; + cancels.remove(&workspace_id); + } return Err("Codex login timed out.".to_string()); } }; + cancel_task.abort(); + { + let mut cancels = state.codex_login_cancels.lock().await; + cancels.remove(&workspace_id); + } + + if canceled.load(Ordering::Relaxed) { + return Err("Codex login canceled.".to_string()); + } + let stdout_bytes = match stdout_task.await { Ok(bytes) => bytes, Err(_) => Vec::new(), @@ -666,6 +711,35 @@ pub(crate) async fn codex_login( Ok(json!({ "output": limited })) } +#[tauri::command] +pub(crate) async fn codex_login_cancel( + workspace_id: String, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + if remote_backend::is_remote_mode(&*state).await { + return remote_backend::call_remote( + &*state, + app, + "codex_login_cancel", + json!({ "workspaceId": workspace_id }), + ) + .await; + } + + let cancel_tx = { + let mut cancels = state.codex_login_cancels.lock().await; + cancels.remove(&workspace_id) + }; + let canceled = if let Some(tx) = cancel_tx { + let _ = tx.send(()); + true + } else { + false + }; + Ok(json!({ "canceled": canceled })) +} + #[tauri::command] pub(crate) async fn skills_list( workspace_id: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f2363d5f3..6c3dd1606 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -145,6 +145,7 @@ pub fn run() { codex::account_rate_limits, codex::account_read, codex::codex_login, + codex::codex_login_cancel, codex::skills_list, prompts::prompts_list, prompts::prompts_create, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index ffad46019..86e481023 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use std::sync::Arc; use tauri::{AppHandle, Manager}; use tokio::sync::Mutex; +use tokio::sync::oneshot; use crate::dictation::DictationState; use crate::storage::{read_settings, read_workspaces}; @@ -18,6 +19,7 @@ pub(crate) struct AppState { pub(crate) settings_path: PathBuf, pub(crate) app_settings: Mutex, pub(crate) dictation: Mutex, + pub(crate) codex_login_cancels: Mutex>>, } impl AppState { @@ -39,6 +41,7 @@ impl AppState { settings_path, app_settings: Mutex::new(app_settings), dictation: Mutex::new(DictationState::default()), + codex_login_cancels: Mutex::new(HashMap::new()), } } } diff --git a/src/App.tsx b/src/App.tsx index 23bf89748..8c119e2c6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -95,7 +95,7 @@ import { useGitCommitController } from "./features/app/hooks/useGitCommitControl import { WorkspaceHome } from "./features/workspaces/components/WorkspaceHome"; import { useWorkspaceHome } from "./features/workspaces/hooks/useWorkspaceHome"; import { useWorkspaceAgentMd } from "./features/workspaces/hooks/useWorkspaceAgentMd"; -import { pickWorkspacePath, runCodexLogin } from "./services/tauri"; +import { cancelCodexLogin, pickWorkspacePath, runCodexLogin } from "./services/tauri"; import type { AccessMode, ComposerEditorSettings, @@ -632,6 +632,7 @@ function MainApp() { onMessageActivity: queueGitStatusRefresh }); const [accountSwitching, setAccountSwitching] = useState(false); + const accountSwitchCanceledRef = useRef(false); const activeAccount = activeWorkspaceId ? accountByWorkspace[activeWorkspaceId] ?? null : null; @@ -1115,19 +1116,38 @@ function MainApp() { [activeWorkspace, connectWorkspace, sendUserMessageToThread, startThreadForWorkspace], ); + const isCodexLoginCanceled = useCallback((error: unknown) => { + const message = + typeof error === "string" ? error : error instanceof Error ? error.message : ""; + const normalized = message.toLowerCase(); + return ( + normalized.includes("codex login canceled") || + normalized.includes("codex login cancelled") || + normalized.includes("request canceled") + ); + }, []); + const handleSwitchAccount = useCallback(async () => { if (!activeWorkspaceId || accountSwitching) { return; } + accountSwitchCanceledRef.current = false; setAccountSwitching(true); try { await runCodexLogin(activeWorkspaceId); + if (accountSwitchCanceledRef.current) { + return; + } await refreshAccountInfo(activeWorkspaceId); await refreshAccountRateLimits(activeWorkspaceId); } catch (error) { + if (accountSwitchCanceledRef.current || isCodexLoginCanceled(error)) { + return; + } alertError(error); } finally { setAccountSwitching(false); + accountSwitchCanceledRef.current = false; } }, [ activeWorkspaceId, @@ -1135,8 +1155,23 @@ function MainApp() { refreshAccountInfo, refreshAccountRateLimits, alertError, + isCodexLoginCanceled, ]); + const handleCancelSwitchAccount = useCallback(async () => { + if (!activeWorkspaceId || !accountSwitching) { + return; + } + accountSwitchCanceledRef.current = true; + try { + await cancelCodexLogin(activeWorkspaceId); + } catch (error) { + alertError(error); + } finally { + setAccountSwitching(false); + } + }, [activeWorkspaceId, accountSwitching, alertError]); + const handleCreatePrompt = useCallback( async (data: { @@ -1563,6 +1598,7 @@ function MainApp() { usageShowRemaining: appSettings.usageShowRemaining, accountInfo: activeAccount, onSwitchAccount: handleSwitchAccount, + onCancelSwitchAccount: handleCancelSwitchAccount, accountSwitching, codeBlockCopyUseModifier: appSettings.composerCodeBlockCopyUseModifier, openAppTargets: appSettings.openAppTargets, diff --git a/src/features/app/components/Sidebar.test.tsx b/src/features/app/components/Sidebar.test.tsx index be901ed7e..08d9658c5 100644 --- a/src/features/app/components/Sidebar.test.tsx +++ b/src/features/app/components/Sidebar.test.tsx @@ -22,6 +22,7 @@ const baseProps = { usageShowRemaining: false, accountInfo: null, onSwitchAccount: vi.fn(), + onCancelSwitchAccount: vi.fn(), accountSwitching: false, onOpenSettings: vi.fn(), onOpenDebug: vi.fn(), diff --git a/src/features/app/components/Sidebar.tsx b/src/features/app/components/Sidebar.tsx index 0b637b035..a0901847d 100644 --- a/src/features/app/components/Sidebar.tsx +++ b/src/features/app/components/Sidebar.tsx @@ -55,6 +55,7 @@ type SidebarProps = { usageShowRemaining: boolean; accountInfo: AccountSnapshot | null; onSwitchAccount: () => void; + onCancelSwitchAccount: () => void; accountSwitching: boolean; onOpenSettings: () => void; onOpenDebug: () => void; @@ -105,6 +106,7 @@ export function Sidebar({ usageShowRemaining, accountInfo, onSwitchAccount, + onCancelSwitchAccount, accountSwitching, onOpenSettings, onOpenDebug, @@ -227,6 +229,7 @@ export function Sidebar({ const accountActionLabel = accountEmail ? "Switch account" : "Sign in"; const showAccountSwitcher = Boolean(activeWorkspaceId); const accountSwitchDisabled = accountSwitching || !activeWorkspaceId; + const accountCancelDisabled = !accountSwitching || !activeWorkspaceId; const pinnedThreadRows = (() => { type ThreadRow = { thread: ThreadSummary; depth: number }; @@ -639,7 +642,10 @@ export function Sidebar({ accountLabel={accountButtonLabel} accountActionLabel={accountActionLabel} accountDisabled={accountSwitchDisabled} + accountSwitching={accountSwitching} + accountCancelDisabled={accountCancelDisabled} onSwitchAccount={onSwitchAccount} + onCancelSwitchAccount={onCancelSwitchAccount} /> ); diff --git a/src/features/app/components/SidebarCornerActions.tsx b/src/features/app/components/SidebarCornerActions.tsx index 9b5e4d4fe..f670c71d0 100644 --- a/src/features/app/components/SidebarCornerActions.tsx +++ b/src/features/app/components/SidebarCornerActions.tsx @@ -1,6 +1,7 @@ import ScrollText from "lucide-react/dist/esm/icons/scroll-text"; import Settings from "lucide-react/dist/esm/icons/settings"; import User from "lucide-react/dist/esm/icons/user"; +import X from "lucide-react/dist/esm/icons/x"; import { useEffect, useRef, useState } from "react"; type SidebarCornerActionsProps = { @@ -11,7 +12,10 @@ type SidebarCornerActionsProps = { accountLabel: string; accountActionLabel: string; accountDisabled: boolean; + accountSwitching: boolean; + accountCancelDisabled: boolean; onSwitchAccount: () => void; + onCancelSwitchAccount: () => void; }; export function SidebarCornerActions({ @@ -22,7 +26,10 @@ export function SidebarCornerActions({ accountLabel, accountActionLabel, accountDisabled, + accountSwitching, + accountCancelDisabled, onSwitchAccount, + onCancelSwitchAccount, }: SidebarCornerActionsProps) { const [accountMenuOpen, setAccountMenuOpen] = useState(false); const accountMenuRef = useRef(null); @@ -67,17 +74,34 @@ export function SidebarCornerActions({
Account
{accountLabel}
- +
+ + {accountSwitching && ( + + )} +
)} diff --git a/src/features/layout/hooks/useLayoutNodes.tsx b/src/features/layout/hooks/useLayoutNodes.tsx index 68aa783c9..8737f8bbb 100644 --- a/src/features/layout/hooks/useLayoutNodes.tsx +++ b/src/features/layout/hooks/useLayoutNodes.tsx @@ -113,6 +113,7 @@ type LayoutNodesOptions = { usageShowRemaining: boolean; accountInfo: AccountSnapshot | null; onSwitchAccount: () => void; + onCancelSwitchAccount: () => void; accountSwitching: boolean; codeBlockCopyUseModifier: boolean; openAppTargets: OpenAppTarget[]; @@ -443,6 +444,7 @@ export function useLayoutNodes(options: LayoutNodesOptions): LayoutNodesResult { usageShowRemaining={options.usageShowRemaining} accountInfo={options.accountInfo} onSwitchAccount={options.onSwitchAccount} + onCancelSwitchAccount={options.onCancelSwitchAccount} accountSwitching={options.accountSwitching} onOpenSettings={options.onOpenSettings} onOpenDebug={options.onOpenDebug} diff --git a/src/services/tauri.ts b/src/services/tauri.ts index a6aeab777..b6494aa59 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -473,6 +473,10 @@ export async function runCodexLogin(workspaceId: string) { return invoke<{ output: string }>("codex_login", { workspaceId }); } +export async function cancelCodexLogin(workspaceId: string) { + return invoke<{ canceled: boolean }>("codex_login_cancel", { workspaceId }); +} + export async function getSkillsList(workspaceId: string) { return invoke("skills_list", { workspaceId }); } diff --git a/src/styles/sidebar.css b/src/styles/sidebar.css index e18f35b08..56212d83e 100644 --- a/src/styles/sidebar.css +++ b/src/styles/sidebar.css @@ -1104,12 +1104,57 @@ white-space: nowrap; } +.sidebar-account-actions-row { + display: flex; + align-items: stretch; + gap: 6px; +} + .sidebar-account-action { width: 100%; justify-content: center; font-size: 11px; } +.sidebar-account-action[aria-busy="true"]:disabled { + opacity: 0.85; +} + +.sidebar-account-action-content { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.sidebar-account-spinner { + width: 12px; + height: 12px; + border-radius: 999px; + border: 2px solid rgba(11, 15, 26, 0.22); + border-top-color: rgba(11, 15, 26, 0.8); + animation: sidebar-account-spin 0.8s linear infinite; +} + +@keyframes sidebar-account-spin { + to { + transform: rotate(360deg); + } +} + +.sidebar-account-cancel { + width: 34px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.sidebar-account-cancel svg { + width: 12px; + height: 12px; +} + .sidebar-corner-actions { position: absolute; left: var(--sidebar-padding); From 57f3827b96832d8306fca4a4a4679631b31487c0 Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Thu, 29 Jan 2026 13:52:03 +0100 Subject: [PATCH 7/7] Refactor account switching logic into custom hook Moved account switching logic from App.tsx to a new useAccountSwitching hook for better separation of concerns and reusability. This change simplifies the main app component and encapsulates related state and handlers. --- src/App.tsx | 76 +++----------- src/features/app/hooks/useAccountSwitching.ts | 99 +++++++++++++++++++ 2 files changed, 113 insertions(+), 62 deletions(-) create mode 100644 src/features/app/hooks/useAccountSwitching.ts diff --git a/src/App.tsx b/src/App.tsx index 8c119e2c6..2ff6d50e2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -95,7 +95,7 @@ import { useGitCommitController } from "./features/app/hooks/useGitCommitControl import { WorkspaceHome } from "./features/workspaces/components/WorkspaceHome"; import { useWorkspaceHome } from "./features/workspaces/hooks/useWorkspaceHome"; import { useWorkspaceAgentMd } from "./features/workspaces/hooks/useWorkspaceAgentMd"; -import { cancelCodexLogin, pickWorkspacePath, runCodexLogin } from "./services/tauri"; +import { pickWorkspacePath } from "./services/tauri"; import type { AccessMode, ComposerEditorSettings, @@ -104,6 +104,7 @@ import type { import { OPEN_APP_STORAGE_KEY } from "./features/app/constants"; import { useOpenAppIcons } from "./features/app/hooks/useOpenAppIcons"; import { useCodeCssVars } from "./features/app/hooks/useCodeCssVars"; +import { useAccountSwitching } from "./features/app/hooks/useAccountSwitching"; const AboutView = lazy(() => import("./features/about/components/AboutView").then((module) => ({ @@ -631,11 +632,18 @@ function MainApp() { customPrompts: prompts, onMessageActivity: queueGitStatusRefresh }); - const [accountSwitching, setAccountSwitching] = useState(false); - const accountSwitchCanceledRef = useRef(false); - const activeAccount = activeWorkspaceId - ? accountByWorkspace[activeWorkspaceId] ?? null - : null; + const { + activeAccount, + accountSwitching, + handleSwitchAccount, + handleCancelSwitchAccount, + } = useAccountSwitching({ + activeWorkspaceId, + accountByWorkspace, + refreshAccountInfo, + refreshAccountRateLimits, + alertError, + }); const activeThreadIdRef = useRef(activeThreadId ?? null); const { getThreadRows } = useThreadRows(threadParentById); useEffect(() => { @@ -1116,62 +1124,6 @@ function MainApp() { [activeWorkspace, connectWorkspace, sendUserMessageToThread, startThreadForWorkspace], ); - const isCodexLoginCanceled = useCallback((error: unknown) => { - const message = - typeof error === "string" ? error : error instanceof Error ? error.message : ""; - const normalized = message.toLowerCase(); - return ( - normalized.includes("codex login canceled") || - normalized.includes("codex login cancelled") || - normalized.includes("request canceled") - ); - }, []); - - const handleSwitchAccount = useCallback(async () => { - if (!activeWorkspaceId || accountSwitching) { - return; - } - accountSwitchCanceledRef.current = false; - setAccountSwitching(true); - try { - await runCodexLogin(activeWorkspaceId); - if (accountSwitchCanceledRef.current) { - return; - } - await refreshAccountInfo(activeWorkspaceId); - await refreshAccountRateLimits(activeWorkspaceId); - } catch (error) { - if (accountSwitchCanceledRef.current || isCodexLoginCanceled(error)) { - return; - } - alertError(error); - } finally { - setAccountSwitching(false); - accountSwitchCanceledRef.current = false; - } - }, [ - activeWorkspaceId, - accountSwitching, - refreshAccountInfo, - refreshAccountRateLimits, - alertError, - isCodexLoginCanceled, - ]); - - const handleCancelSwitchAccount = useCallback(async () => { - if (!activeWorkspaceId || !accountSwitching) { - return; - } - accountSwitchCanceledRef.current = true; - try { - await cancelCodexLogin(activeWorkspaceId); - } catch (error) { - alertError(error); - } finally { - setAccountSwitching(false); - } - }, [activeWorkspaceId, accountSwitching, alertError]); - const handleCreatePrompt = useCallback( async (data: { diff --git a/src/features/app/hooks/useAccountSwitching.ts b/src/features/app/hooks/useAccountSwitching.ts new file mode 100644 index 000000000..c701f0a96 --- /dev/null +++ b/src/features/app/hooks/useAccountSwitching.ts @@ -0,0 +1,99 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { cancelCodexLogin, runCodexLogin } from "../../../services/tauri"; +import type { AccountSnapshot } from "../../../types"; + +type UseAccountSwitchingArgs = { + activeWorkspaceId: string | null; + accountByWorkspace: Record; + refreshAccountInfo: (workspaceId: string) => Promise | void; + refreshAccountRateLimits: (workspaceId: string) => Promise | void; + alertError: (error: unknown) => void; +}; + +type UseAccountSwitchingResult = { + activeAccount: AccountSnapshot | null; + accountSwitching: boolean; + handleSwitchAccount: () => Promise; + handleCancelSwitchAccount: () => Promise; +}; + +export function useAccountSwitching({ + activeWorkspaceId, + accountByWorkspace, + refreshAccountInfo, + refreshAccountRateLimits, + alertError, +}: UseAccountSwitchingArgs): UseAccountSwitchingResult { + const [accountSwitching, setAccountSwitching] = useState(false); + const accountSwitchCanceledRef = useRef(false); + + const activeAccount = useMemo(() => { + if (!activeWorkspaceId) { + return null; + } + return accountByWorkspace[activeWorkspaceId] ?? null; + }, [activeWorkspaceId, accountByWorkspace]); + + const isCodexLoginCanceled = useCallback((error: unknown) => { + const message = + typeof error === "string" ? error : error instanceof Error ? error.message : ""; + const normalized = message.toLowerCase(); + return ( + normalized.includes("codex login canceled") || + normalized.includes("codex login cancelled") || + normalized.includes("request canceled") + ); + }, []); + + const handleSwitchAccount = useCallback(async () => { + if (!activeWorkspaceId || accountSwitching) { + return; + } + accountSwitchCanceledRef.current = false; + setAccountSwitching(true); + try { + await runCodexLogin(activeWorkspaceId); + if (accountSwitchCanceledRef.current) { + return; + } + await refreshAccountInfo(activeWorkspaceId); + await refreshAccountRateLimits(activeWorkspaceId); + } catch (error) { + if (accountSwitchCanceledRef.current || isCodexLoginCanceled(error)) { + return; + } + alertError(error); + } finally { + setAccountSwitching(false); + accountSwitchCanceledRef.current = false; + } + }, [ + activeWorkspaceId, + accountSwitching, + refreshAccountInfo, + refreshAccountRateLimits, + alertError, + isCodexLoginCanceled, + ]); + + const handleCancelSwitchAccount = useCallback(async () => { + if (!activeWorkspaceId || !accountSwitching) { + return; + } + accountSwitchCanceledRef.current = true; + try { + await cancelCodexLogin(activeWorkspaceId); + } catch (error) { + alertError(error); + } finally { + setAccountSwitching(false); + } + }, [activeWorkspaceId, accountSwitching, alertError]); + + return { + activeAccount, + accountSwitching, + handleSwitchAccount, + handleCancelSwitchAccount, + }; +}