diff --git a/CHANGELOG.md b/CHANGELOG.md index 800ec2ca03..390c21148b 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ## [Windows] Unreleased +- Settings providers: show only enabled providers from the sidebar options menu, search by CLI alias (such as `openai` for Codex), restore the default provider order, and undo a drag reorder or a restore with Ctrl+Z or the options menu. Hidden providers keep their slots when the order changes (upstream #4440). - Add **Color by provider** to Settings > Menu Bar (off by default): tints the tray usage bars and percent text with the shown provider's brand or accent color, keeps pace colors and the error colors unchanged, and falls back to the usual colors when a tint would be hard to see on the tray background (upstream #4321). - OpenAI API: each Admin refresh also reads the last hour of 1-minute usage from the Admin Usage API (input plus output tokens, cached input included, Batch API traffic kept out of limit headroom) and carries a Tokens/min grid with a reporting frontier, so buckets that arrive late show as pending instead of zero. The backend can also load 15-minute, 1-hour or 6-hour windows, read per-project rate-limit caps, and measure headroom against manual per-model organization limits stored in settings. The per-refresh request can be turned off, and a failure never affects the daily usage. The chart, menu bar token and Settings controls come in a later release (upstream #4495). - **Claude**: show the plan renewal or expiry date from claude.ai billing under the quota rows when your cookie session supplies it, and make saved limit-reset credits, Extra usage (including a prepaid balance), and plan dates independently selectable under Visible usage items. Existing settings keep all three visible; Admin API spend is unaffected (upstream #4324, #4484). diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index ad1a3b3de0..b999c12604 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -945,6 +945,8 @@ pub struct ProviderCatalogEntry { pub(crate) id: String, pub(crate) display_name: String, pub(crate) cookie_domain: Option, + /// CLI aliases the Settings search also matches (see `ProviderId::cli_aliases`). + pub(crate) cli_aliases: Vec, } /// One provider's service status for the sidebar dot and the tray panel. @@ -1019,6 +1021,7 @@ pub struct SettingsSnapshot { menu_bar_color_by_provider: bool, menu_bar_pace_reserve_positive: bool, providers_sorted_alphabetically: bool, + providers_enabled_only: bool, show_as_used: bool, show_all_token_accounts_in_menu: bool, enable_animations: bool, @@ -1189,6 +1192,7 @@ impl From for SettingsSnapshot { menu_bar_color_by_provider: settings.menu_bar_color_by_provider, menu_bar_pace_reserve_positive: settings.menu_bar_pace_reserve_positive, providers_sorted_alphabetically: settings.providers_sorted_alphabetically, + providers_enabled_only: settings.providers_enabled_only, show_as_used: settings.show_as_used, show_all_token_accounts_in_menu: settings.show_all_token_accounts_in_menu, enable_animations: settings.enable_animations, @@ -1284,6 +1288,11 @@ pub(crate) fn provider_catalog_for(settings: &Settings) -> Vec, +) -> Result, String> { + let mut settings = Settings::load(); + let full = settings.provider_display_order_names(); + settings.provider_order = codexbar::settings::merge_visible_provider_order(&full, &visible) + .ok_or_else(|| "Provider order names an unknown or repeated provider".to_string())?; + settings.save().map_err(|e| e.to_string())?; + crate::tray_bridge::refresh_tray_presentation(app); + crate::events::emit_settings_changed(app); + Ok(build_provider_summaries(&settings)) +} + +/// Settings sidebar reorder: `ids` is the full visible order after the move. +#[tauri::command] +pub fn reorder_visible_providers( + app: tauri::AppHandle, + ids: Vec, +) -> Result, String> { + save_visible_provider_order(&app, ids) +} + +/// Restore the built-in order for the providers the sidebar lists. +#[tauri::command] +pub fn restore_default_provider_order( + app: tauri::AppHandle, +) -> Result, String> { + let visible: Vec = { + let settings = Settings::load(); + ProviderId::all() + .iter() + .filter(|provider| settings.is_provider_listed(**provider)) + .map(|provider| provider.cli_name().to_string()) + .collect() + }; + save_visible_provider_order(&app, visible) +} + // ── Per-provider usage source ───────────────────────────────────────── pub(crate) fn provider_usage_source_lookup( diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index 9967e17fee..8e88d36432 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -47,6 +47,7 @@ pub struct SettingsUpdate { pub menu_bar_color_by_provider: Option, pub menu_bar_pace_reserve_positive: Option, pub providers_sorted_alphabetically: Option, + pub providers_enabled_only: Option, pub show_as_used: Option, pub show_all_token_accounts_in_menu: Option, pub enable_animations: Option, @@ -406,6 +407,9 @@ impl SettingsUpdate { if let Some(v) = self.providers_sorted_alphabetically { settings.providers_sorted_alphabetically = v; } + if let Some(v) = self.providers_enabled_only { + settings.providers_enabled_only = v; + } if let Some(v) = self.show_all_token_accounts_in_menu { settings.show_all_token_accounts_in_menu = v; } diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index 2b5f02b9de..c42aedbee7 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -284,6 +284,8 @@ fn main() { commands::get_spend_contract, commands::get_codex_workspaces_snapshot, commands::reorder_providers, + commands::reorder_visible_providers, + commands::restore_default_provider_order, commands::set_provider_cookie_source, commands::set_provider_usage_source, commands::set_provider_auto_resume_after_quota_reset, diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index 02ecc6c673..0a8709d26f 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -556,11 +556,13 @@ mod tests { id: "codex".into(), display_name: "Codex".into(), cookie_domain: None, + cli_aliases: Vec::new(), }, ProviderCatalogEntry { id: "claude".into(), display_name: "Claude".into(), cookie_domain: None, + cli_aliases: Vec::new(), }, ] } diff --git a/apps/desktop-tauri/src-tauri/src/tray_menu.rs b/apps/desktop-tauri/src-tauri/src/tray_menu.rs index 3498739af8..f259b4e72b 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_menu.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_menu.rs @@ -195,11 +195,13 @@ mod tests { id: "codex".into(), display_name: "Codex".into(), cookie_domain: None, + cli_aliases: Vec::new(), }, ProviderCatalogEntry { id: "claude".into(), display_name: "Claude".into(), cookie_domain: None, + cli_aliases: Vec::new(), }, ] } diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index a1c31e2847..2ea4188d82 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -940,6 +940,10 @@ export const ALL_LOCALE_KEYS = [ "ProviderSidebarReorderHint", "ProviderSidebarMoveUp", "ProviderSidebarMoveDown", + "ProviderSidebarOptions", + "ProviderSidebarShowEnabledOnly", + "ProviderSidebarRestoreDefaultOrder", + "ProviderSidebarUndoOrder", "SettingsSidebarSortProviders", "SettingsSidebarSortOffHint", "SettingsSidebarSortOnHint", diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index e752e57912..59691560ad 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -93,6 +93,16 @@ export function reorderProviders(ids: string[]): Promise { return invoke("reorder_providers", { ids }); } +/** Settings sidebar reorder: `ids` is the full listed order; hidden providers keep their slots. */ +export function reorderVisibleProviders(ids: string[]): Promise { + return invoke("reorder_visible_providers", { ids }); +} + +/** Restore the built-in order for the providers the Settings sidebar lists. */ +export function restoreDefaultProviderOrder(): Promise { + return invoke("restore_default_provider_order"); +} + export function getSettingsSnapshot(): Promise { return invoke("get_settings_snapshot"); } diff --git a/apps/desktop-tauri/src/surfaces/Settings.test.tsx b/apps/desktop-tauri/src/surfaces/Settings.test.tsx index ab24f2b742..e9af33738e 100644 --- a/apps/desktop-tauri/src/surfaces/Settings.test.tsx +++ b/apps/desktop-tauri/src/surfaces/Settings.test.tsx @@ -1,10 +1,11 @@ -import { fireEvent, render, screen, within } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { BootstrapState, SurfaceTarget } from "../types/bridge"; const mocks = vi.hoisted(() => ({ update: vi.fn(), - reorderProviders: vi.fn(), + reorderVisibleProviders: vi.fn(), + restoreDefaultProviderOrder: vi.fn(), setSurfaceMode: vi.fn(), windowLabel: "settings", shellTarget: null as SurfaceTarget | null, @@ -27,7 +28,8 @@ vi.mock("../lib/tauri", () => ({ getWorkAreaRect: () => Promise.resolve(null), getProviderStatuses: () => Promise.resolve([]), setSurfaceMode: mocks.setSurfaceMode, - reorderProviders: mocks.reorderProviders, + reorderVisibleProviders: mocks.reorderVisibleProviders, + restoreDefaultProviderOrder: mocks.restoreDefaultProviderOrder, })); vi.mock("../hooks/useSettings", () => ({ useSettings: (settings: unknown) => ({ @@ -102,7 +104,8 @@ describe("Settings sidebar", () => { vi.clearAllMocks(); mocks.windowLabel = "settings"; mocks.shellTarget = null; - mocks.reorderProviders.mockResolvedValue(undefined); + mocks.reorderVisibleProviders.mockResolvedValue([]); + mocks.restoreDefaultProviderOrder.mockResolvedValue([]); }); it("lists the app panes in the macOS order, then the providers with an on count", () => { @@ -165,7 +168,7 @@ describe("Settings sidebar", () => { expect(sort).toHaveAttribute("title", "SettingsSidebarSortOnHint"); fireEvent.keyDown(row("codex"), { key: "ArrowDown", altKey: true }); - expect(mocks.reorderProviders).not.toHaveBeenCalled(); + expect(mocks.reorderVisibleProviders).not.toHaveBeenCalled(); fireEvent.click(sort); expect(mocks.update).toHaveBeenCalledWith({ providersSortedAlphabetically: false }); @@ -195,7 +198,13 @@ describe("Settings sidebar", () => { it("reorders with Alt+Arrow and saves the new order", () => { render(); fireEvent.keyDown(row("codex"), { key: "ArrowDown", altKey: true }); - expect(mocks.reorderProviders).toHaveBeenCalledWith(["zai", "codex", "claude", "cline", "grok"]); + expect(mocks.reorderVisibleProviders).toHaveBeenCalledWith([ + "zai", + "codex", + "claude", + "cline", + "grok", + ]); expect(providerNames()).toEqual(["zai", "codex", "claude", "cline", "grok"]); }); @@ -203,7 +212,109 @@ describe("Settings sidebar", () => { render(); fireEvent.change(screen.getByRole("searchbox"), { target: { value: "c" } }); fireEvent.keyDown(row("codex"), { key: "ArrowDown", altKey: true }); - expect(mocks.reorderProviders).not.toHaveBeenCalled(); + expect(mocks.reorderVisibleProviders).not.toHaveBeenCalled(); + }); + + const optionsButton = () => screen.getByRole("button", { name: "ProviderSidebarOptions" }); + const undoItem = () => screen.getByRole("menuitem", { name: "ProviderSidebarUndoOrder" }); + + it("shows only enabled providers with the enabled-only option, and does not reorder then", () => { + render(); + expect(providerNames()).toEqual(["codex", "zai", "grok"]); + fireEvent.keyDown(row("codex"), { key: "ArrowDown", altKey: true }); + expect(mocks.reorderVisibleProviders).not.toHaveBeenCalled(); + + fireEvent.click(optionsButton()); + expect(screen.getByRole("menuitemcheckbox", { name: "ProviderSidebarShowEnabledOnly" })).toHaveAttribute( + "aria-checked", + "true", + ); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "ProviderSidebarShowEnabledOnly" })); + expect(mocks.update).toHaveBeenCalledWith({ providersEnabledOnly: false }); + }); + + it("turns the enabled-only option on from the options menu", () => { + render(); + fireEvent.click(optionsButton()); + fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "ProviderSidebarShowEnabledOnly" })); + expect(mocks.update).toHaveBeenCalledWith({ providersEnabledOnly: true }); + }); + + it("undoes the last drag reorder from the options menu", () => { + render(); + fireEvent.keyDown(row("codex"), { key: "ArrowDown", altKey: true }); + expect(providerNames()).toEqual(["zai", "codex", "claude", "cline", "grok"]); + + fireEvent.click(optionsButton()); + fireEvent.click(undoItem()); + expect(mocks.reorderVisibleProviders).toHaveBeenLastCalledWith([ + "codex", + "zai", + "claude", + "cline", + "grok", + ]); + expect(providerNames()).toEqual(["codex", "zai", "claude", "cline", "grok"]); + }); + + it("undoes the last drag reorder with Ctrl+Z", () => { + const { container } = render(); + fireEvent.keyDown(row("codex"), { key: "ArrowDown", altKey: true }); + fireEvent.keyDown(container.querySelector(".settings-sidebar")!, { key: "z", ctrlKey: true }); + expect(mocks.reorderVisibleProviders).toHaveBeenLastCalledWith([ + "codex", + "zai", + "claude", + "cline", + "grok", + ]); + }); + + it("drops the undo once the saved order changes outside the sidebar", () => { + const { rerender } = render(); + fireEvent.keyDown(row("codex"), { key: "ArrowDown", altKey: true }); + fireEvent.click(optionsButton()); + expect(undoItem()).toBeEnabled(); + fireEvent.click(optionsButton()); + + const moved = [...providers].reverse(); + rerender(); + fireEvent.click(optionsButton()); + expect(undoItem()).toBeDisabled(); + }); + + it("restores the default order and turns sorting off, and undo brings both back", async () => { + mocks.restoreDefaultProviderOrder.mockResolvedValue( + providers.map((p, order) => ({ id: p.id, displayName: p.displayName, enabled: true, order })), + ); + render(); + fireEvent.click(optionsButton()); + fireEvent.click(screen.getByRole("menuitem", { name: "ProviderSidebarRestoreDefaultOrder" })); + expect(mocks.update).toHaveBeenCalledWith({ providersSortedAlphabetically: false }); + expect(mocks.restoreDefaultProviderOrder).toHaveBeenCalledTimes(1); + + fireEvent.click(optionsButton()); + await waitFor(() => expect(undoItem()).toBeEnabled()); + fireEvent.click(undoItem()); + expect(mocks.update).toHaveBeenLastCalledWith({ providersSortedAlphabetically: true }); + expect(mocks.reorderVisibleProviders).toHaveBeenLastCalledWith([ + "codex", + "zai", + "claude", + "cline", + "grok", + ]); + }); + + it("turns sorting back on when restoring the default order fails", async () => { + mocks.restoreDefaultProviderOrder.mockRejectedValue(new Error("save failed")); + render(); + fireEvent.click(optionsButton()); + fireEvent.click(screen.getByRole("menuitem", { name: "ProviderSidebarRestoreDefaultOrder" })); + expect(mocks.update).toHaveBeenCalledWith({ providersSortedAlphabetically: false }); + await waitFor(() => + expect(mocks.update).toHaveBeenLastCalledWith({ providersSortedAlphabetically: true }), + ); }); it("drives the main window surface only outside the detached window", () => { diff --git a/apps/desktop-tauri/src/surfaces/settings/SettingsSidebar.tsx b/apps/desktop-tauri/src/surfaces/settings/SettingsSidebar.tsx index 43bd24e62f..d657f98032 100644 --- a/apps/desktop-tauri/src/surfaces/settings/SettingsSidebar.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/SettingsSidebar.tsx @@ -8,17 +8,21 @@ import type { } from "../../types/bridge"; import { useLocale } from "../../hooks/useLocale"; import { useProviderStatuses } from "../../hooks/useProviderStatuses"; -import { reorderProviders } from "../../lib/tauri"; +import { reorderVisibleProviders, restoreDefaultProviderOrder } from "../../lib/tauri"; import codexbarIcon from "../../assets/codexbar-icon.png"; import { SIDEBAR_PANES, canReorderProviders, + canUndoProviderOrder, filterProvidersByQuery, nextEnabledProviders, + sameIds, sortProvidersAlphabetically, + type ProviderOrderUndo, type SidebarPane, } from "./settingsTabs"; import { + ProviderRowMenu, ProvidersSidebar, type ProviderSidebarRow, type ProviderSidebarStatus, @@ -162,19 +166,25 @@ export function SettingsSidebar({ } const sorted = settings.providersSortedAlphabetically; + const enabledOnly = settings.providersEnabledOnly === true; const enabled = useMemo(() => new Set(settings.enabledProviders), [settings.enabledProviders]); const enabledCount = ordered.filter((p) => enabled.has(p.id)).length; const serviceStatuses = useProviderStatuses(); + // The last saved order change, for Undo. It goes stale once the order moves again. + const [undo, setUndo] = useState(null); + const [filterMenu, setFilterMenu] = useState<{ x: number; y: number } | null>(null); + const undoable = canUndoProviderOrder(undo, ordered.map((p) => p.id)); const rows: ProviderSidebarRow[] = useMemo(() => { const snapshotMap = new Map(snapshots.map((s) => [s.providerId, s])); const serviceMap = new Map( serviceStatuses.map((e) => [e.providerId, e.status?.indicator ?? "unknown"] as const), ); - const shown = filterProvidersByQuery( + const searched = filterProvidersByQuery( sorted ? sortProvidersAlphabetically(ordered, enabled) : ordered, searchText, ); + const shown = enabledOnly ? searched.filter((p) => enabled.has(p.id)) : searched; return shown.map((p) => { const isOn = enabled.has(p.id); return { @@ -185,22 +195,74 @@ export function SettingsSidebar({ service: isOn ? serviceMap.get(p.id) : undefined, }; }); - }, [enabled, ordered, searchText, serviceStatuses, snapshots, sorted]); + }, [enabled, enabledOnly, ordered, searchText, serviceStatuses, snapshots, sorted]); const toggleEnabled = (id: string, on: boolean) => { set({ enabledProviders: nextEnabledProviders(ordered, enabled, id, on) }); }; - const handleReorder = (ids: string[]) => { + /** Shows `ids` at once; the saved order follows from the command. */ + const showOrder = (ids: string[]) => { const byId = new Map(ordered.map((p) => [p.id, p])); setOrdered(ids.map((id) => byId.get(id)).filter((p): p is ProviderCatalogEntry => Boolean(p))); - void reorderProviders(ids).catch(() => setOrdered(providers)); + }; + + const handleReorder = (ids: string[]) => { + const entry: ProviderOrderUndo = { + before: ordered.map((p) => p.id), + after: ids, + restoreAlphabetical: null, + }; + showOrder(ids); + setUndo(entry); + void reorderVisibleProviders(ids).catch(() => { + setOrdered(providers); + setUndo((current) => (current === entry ? null : current)); + }); + }; + + /** Restores the built-in order and turns alphabetical sorting off; undo brings both back. */ + const restoreOrder = () => { + const before = ordered.map((p) => p.id); + const wasSorted = sorted; + if (wasSorted) set({ providersSortedAlphabetically: false }); + void restoreDefaultProviderOrder() + .then((summaries) => { + const ids = summaries.map((s) => s.id); + showOrder(ids); + if (wasSorted || !sameIds(before, ids)) { + setUndo({ before, after: ids, restoreAlphabetical: wasSorted ? true : null }); + } + }) + .catch(() => { + if (wasSorted) set({ providersSortedAlphabetically: true }); + }); + }; + + const undoOrder = () => { + if (!undo || !undoable || saving) return; + const entry = undo; + setUndo(null); + showOrder(entry.before); + if (entry.restoreAlphabetical) set({ providersSortedAlphabetically: true }); + void reorderVisibleProviders(entry.before).catch(() => setOrdered(providers)); }; const sortHint = t(sorted ? "SettingsSidebarSortOnHint" : "SettingsSidebarSortOffHint"); return ( -