Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
401 changes: 398 additions & 3 deletions src-tauri/src/bin/codex_monitor_daemon.rs

Large diffs are not rendered by default.

448 changes: 446 additions & 2 deletions src-tauri/src/codex.rs

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@ pub fn run() {
git::create_git_branch,
codex::model_list,
codex::account_rate_limits,
codex::account_read,
codex::codex_login,
codex::codex_login_cancel,
codex::skills_list,
prompts::prompts_list,
prompts::prompts_create,
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -18,6 +19,7 @@ pub(crate) struct AppState {
pub(crate) settings_path: PathBuf,
pub(crate) app_settings: Mutex<AppSettings>,
pub(crate) dictation: Mutex<DictationState>,
pub(crate) codex_login_cancels: Mutex<HashMap<String, oneshot::Sender<()>>>,
}

impl AppState {
Expand All @@ -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()),
}
}
}
20 changes: 20 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down Expand Up @@ -596,6 +597,7 @@ function MainApp() {
threadListCursorByWorkspace,
tokenUsageByThread,
rateLimitsByWorkspace,
accountByWorkspace,
planByThread,
lastAgentMessageByThread,
interruptTurn,
Expand All @@ -616,6 +618,8 @@ function MainApp() {
handleApprovalDecision,
handleApprovalRemember,
handleUserInputSubmit,
refreshAccountInfo,
refreshAccountRateLimits,
} = useThreads({
activeWorkspace,
onWorkspaceConnected: markWorkspaceConnected,
Expand All @@ -628,6 +632,18 @@ function MainApp() {
customPrompts: prompts,
onMessageActivity: queueGitStatusRefresh
});
const {
activeAccount,
accountSwitching,
handleSwitchAccount,
handleCancelSwitchAccount,
} = useAccountSwitching({
activeWorkspaceId,
accountByWorkspace,
refreshAccountInfo,
refreshAccountRateLimits,
alertError,
});
const activeThreadIdRef = useRef<string | null>(activeThreadId ?? null);
const { getThreadRows } = useThreadRows(threadParentById);
useEffect(() => {
Expand Down Expand Up @@ -1532,6 +1548,10 @@ function MainApp() {
activeItems,
activeRateLimits,
usageShowRemaining: appSettings.usageShowRemaining,
accountInfo: activeAccount,
onSwitchAccount: handleSwitchAccount,
onCancelSwitchAccount: handleCancelSwitchAccount,
accountSwitching,
codeBlockCopyUseModifier: appSettings.composerCodeBlockCopyUseModifier,
openAppTargets: appSettings.openAppTargets,
openAppIconById,
Expand Down
4 changes: 4 additions & 0 deletions src/features/app/components/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ const baseProps = {
activeThreadId: null,
accountRateLimits: null,
usageShowRemaining: false,
accountInfo: null,
onSwitchAccount: vi.fn(),
onCancelSwitchAccount: vi.fn(),
accountSwitching: false,
onOpenSettings: vi.fn(),
onOpenDebug: vi.fn(),
showDebugButton: false,
Expand Down
34 changes: 33 additions & 1 deletion src/features/app/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -48,6 +53,10 @@ type SidebarProps = {
activeThreadId: string | null;
accountRateLimits: RateLimitSnapshot | null;
usageShowRemaining: boolean;
accountInfo: AccountSnapshot | null;
onSwitchAccount: () => void;
onCancelSwitchAccount: () => void;
accountSwitching: boolean;
onOpenSettings: () => void;
onOpenDebug: () => void;
showDebugButton: boolean;
Expand Down Expand Up @@ -95,6 +104,10 @@ export function Sidebar({
activeThreadId,
accountRateLimits,
usageShowRemaining,
accountInfo,
onSwitchAccount,
onCancelSwitchAccount,
accountSwitching,
onOpenSettings,
onOpenDebug,
showDebugButton,
Expand Down Expand Up @@ -207,6 +220,17 @@ 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 accountCancelDisabled = !accountSwitching || !activeWorkspaceId;

const pinnedThreadRows = (() => {
type ThreadRow = { thread: ThreadSummary; depth: number };
const groups: Array<{
Expand Down Expand Up @@ -614,6 +638,14 @@ export function Sidebar({
onOpenSettings={onOpenSettings}
onOpenDebug={onOpenDebug}
showDebugButton={showDebugButton}
showAccountSwitcher={showAccountSwitcher}
accountLabel={accountButtonLabel}
accountActionLabel={accountActionLabel}
accountDisabled={accountSwitchDisabled}
accountSwitching={accountSwitching}
accountCancelDisabled={accountCancelDisabled}
onSwitchAccount={onSwitchAccount}
onCancelSwitchAccount={onCancelSwitchAccount}
/>
</aside>
);
Expand Down
92 changes: 92 additions & 0 deletions src/features/app/components/SidebarCornerActions.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,111 @@
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 = {
onOpenSettings: () => void;
onOpenDebug: () => void;
showDebugButton: boolean;
showAccountSwitcher: boolean;
accountLabel: string;
accountActionLabel: string;
accountDisabled: boolean;
accountSwitching: boolean;
accountCancelDisabled: boolean;
onSwitchAccount: () => void;
onCancelSwitchAccount: () => void;
};

export function SidebarCornerActions({
onOpenSettings,
onOpenDebug,
showDebugButton,
showAccountSwitcher,
accountLabel,
accountActionLabel,
accountDisabled,
accountSwitching,
accountCancelDisabled,
onSwitchAccount,
onCancelSwitchAccount,
}: SidebarCornerActionsProps) {
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const accountMenuRef = useRef<HTMLDivElement | null>(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 (
<div className="sidebar-corner-actions">
{showAccountSwitcher && (
<div className="sidebar-account-menu" ref={accountMenuRef}>
<button
className="ghost sidebar-corner-button"
type="button"
onClick={() => setAccountMenuOpen((open) => !open)}
aria-label="Account"
title="Account"
>
<User size={14} aria-hidden />
</button>
{accountMenuOpen && (
<div className="sidebar-account-popover popover-surface" role="dialog">
<div className="sidebar-account-title">Account</div>
<div className="sidebar-account-value">{accountLabel}</div>
<div className="sidebar-account-actions-row">
<button
type="button"
className="primary sidebar-account-action"
onClick={onSwitchAccount}
disabled={accountDisabled}
aria-busy={accountSwitching}
>
<span className="sidebar-account-action-content">
{accountSwitching && (
<span className="sidebar-account-spinner" aria-hidden />
)}
<span>{accountActionLabel}</span>
</span>
</button>
{accountSwitching && (
<button
type="button"
className="secondary sidebar-account-cancel"
onClick={onCancelSwitchAccount}
disabled={accountCancelDisabled}
aria-label="Cancel account switch"
title="Cancel"
>
<X size={12} aria-hidden />
</button>
)}
</div>
</div>
)}
</div>
)}
<button
className="ghost sidebar-corner-button"
type="button"
Expand Down
99 changes: 99 additions & 0 deletions src/features/app/hooks/useAccountSwitching.ts
Original file line number Diff line number Diff line change
@@ -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<string, AccountSnapshot | null | undefined>;
refreshAccountInfo: (workspaceId: string) => Promise<void> | void;
refreshAccountRateLimits: (workspaceId: string) => Promise<void> | void;
alertError: (error: unknown) => void;
};

type UseAccountSwitchingResult = {
activeAccount: AccountSnapshot | null;
accountSwitching: boolean;
handleSwitchAccount: () => Promise<void>;
handleCancelSwitchAccount: () => Promise<void>;
};

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,
};
}
Loading