diff --git a/.github/pr-assets/account-actions-popover-synthetic.jpg b/.github/pr-assets/account-actions-popover-synthetic.jpg new file mode 100644 index 0000000000..21e9e806ac Binary files /dev/null and b/.github/pr-assets/account-actions-popover-synthetic.jpg differ diff --git a/gui/src/components/codex-account-pool-cards.tsx b/gui/src/components/codex-account-pool-cards.tsx index 619ec2ecf2..8817ed1616 100644 --- a/gui/src/components/codex-account-pool-cards.tsx +++ b/gui/src/components/codex-account-pool-cards.tsx @@ -83,6 +83,20 @@ export function CodexAccountPoolCards({ const validationPending = a.health?.reason === "validation_pending"; const healthLabel = formatOAuthHealthLabel(t, a.health); const healthSummary = formatOAuthHealthSummary(t, "codex", a.id, a.health); + const hasCustomPriority = normalizeAccountPriority(a.priority) !== DEFAULT_ACCOUNT_PRIORITY; + const priorityControl = ( + onPriorityChange(a, priority)} + /> + ); return (
@@ -153,6 +167,7 @@ export function CodexAccountPoolCards({ >
+ {!hasCustomPriority && moreOpen.has(a.id) && priorityControl} {t("prov.accountId")}: {displayAccountId(a.id)}
{a.email}{a.plan ? ` · ${a.plan}` : ""}
- {(normalizeAccountPriority(a.priority) !== DEFAULT_ACCOUNT_PRIORITY || moreOpen.has(a.id)) && ( - onPriorityChange(a, priority)} - /> - )} + {hasCustomPriority && priorityControl}
{healthSummary && (
{healthSummary}
diff --git a/gui/src/styles-codex-set.css b/gui/src/styles-codex-set.css index 25daedcd68..d23ac05799 100644 --- a/gui/src/styles-codex-set.css +++ b/gui/src/styles-codex-set.css @@ -441,3 +441,37 @@ .codex-main-hard-lock-dialog .modal-desc { text-wrap: pretty; } :lang(ko) .codex-main-hard-lock-copy .card-sub, :lang(ko) .codex-main-hard-lock-dialog .modal-desc { word-break: keep-all; } + +/* Account-card ⋯ actions share the dashboard's compact popover language instead of + reading as a wide floating toolbar. This selector intentionally outranks the later + single-class rule in styles.css without moving layout ownership back inline. */ +.codex-account-more .codex-account-more-body { + box-sizing: border-box; + z-index: var(--z-popover); + min-width: min(16rem, calc(100vw - 2rem)); + max-width: min(22rem, calc(100vw - 2rem)); + padding: 10px 12px; + gap: 6px; + justify-content: flex-start; + background: var(--raised); + border-radius: var(--radius); + box-shadow: 0 4px 24px rgb(0 0 0 / 0.14); +} + +/* Layout ownership lives here too. The type-qualified selectors outrank the legacy + single-class declarations later in styles.css, so the disclosure stays out of card flow + even after styles.css is refreshed from upstream. */ +details.codex-account-more { + position: relative; + display: inline-block; +} + +details.codex-account-more > .codex-account-more-body { + position: absolute; + top: calc(100% + 6px); + right: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + flex-basis: auto; +} diff --git a/gui/tests/codex-account-more-popover-style.test.ts b/gui/tests/codex-account-more-popover-style.test.ts new file mode 100644 index 0000000000..ed968a3f0f --- /dev/null +++ b/gui/tests/codex-account-more-popover-style.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test"; + +function ruleBody(css: string, selector: string): string { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`${escaped}\\s*\\{([^}]*)\\}`).exec(css)?.[1] ?? ""; +} + +test("account more-actions uses the compact dashboard popover language", async () => { + const css = await Bun.file(new URL("../src/styles-codex-set.css", import.meta.url)).text(); + const wrapper = ruleBody(css, "details.codex-account-more"); + const layout = ruleBody(css, "details.codex-account-more > .codex-account-more-body"); + const visual = ruleBody(css, ".codex-account-more .codex-account-more-body"); + + expect(wrapper).toMatch(/position:\s*relative/); + expect(wrapper).toMatch(/display:\s*inline-block/); + expect(layout).toMatch(/position:\s*absolute/); + expect(layout).toMatch(/top:\s*calc\(100% \+ 6px\)/); + expect(layout).toMatch(/right:\s*0/); + expect(layout).toMatch(/flex-basis:\s*auto/); + + expect(visual).toMatch(/z-index:\s*var\(--z-popover\)/); + expect(visual).toMatch(/min-width:\s*min\(16rem, calc\(100vw - 2rem\)\)/); + expect(visual).toMatch(/max-width:\s*min\(22rem, calc\(100vw - 2rem\)\)/); + expect(visual).toMatch(/background:\s*var\(--raised\)/); + expect(visual).toMatch(/border-radius:\s*var\(--radius\)/); + expect(visual).toMatch(/box-shadow:\s*0 4px 24px rgb\(0 0 0 \/ 0\.14\)/); + expect(visual).toMatch(/justify-content:\s*flex-start/); +}); diff --git a/gui/tests/codex-account-more-priority-placement.test.tsx b/gui/tests/codex-account-more-priority-placement.test.tsx new file mode 100644 index 0000000000..4fb49dddc3 --- /dev/null +++ b/gui/tests/codex-account-more-priority-placement.test.tsx @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { CodexAccountPoolCards } from "../src/components/codex-account-pool-cards"; +import type { CodexAccountEntry } from "../src/components/codex-account-pool-types"; +import { LanguageProvider } from "../src/i18n/provider"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +type GlobalName = (typeof globals)[number]; +const prioritySelector = "#codex-account-priority-pool-1"; + +let previous: Record; +let testWindow: Window; +let root: Root | null = null; +let host: HTMLElement; + +function restoreProperty(target: object, key: PropertyKey, descriptor: PropertyDescriptor | undefined): void { + if (descriptor) Object.defineProperty(target, key, descriptor); + else Reflect.deleteProperty(target, key); +} + +beforeEach(() => { + previous = Object.fromEntries( + globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as typeof previous; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); + host = testWindow.document.createElement("div") as never as HTMLElement; + testWindow.document.body.appendChild(host as never); +}); + +afterEach(async () => { + if (root) await act(async () => root?.unmount()); + root = null; + for (const key of globals) restoreProperty(globalThis, key, previous[key]); + await testWindow.happyDOM?.close?.(); +}); + +function account(priority: number): CodexAccountEntry { + return { + id: "pool-1", + email: "pool@example.test", + isMain: false, + paused: false, + priority, + hasCredential: true, + quota: null, + quotaAutoRefresh: { + fiveHourAvailable: false, + weeklyAvailable: false, + fiveHourEnabled: false, + weeklyEnabled: false, + }, + }; +} + +async function mount(priority: number): Promise { + await act(async () => { + root = createRoot(host); + root.render( + + undefined} + onSwitch={() => undefined} + onTogglePause={() => undefined} + pauseUpdatingId={null} + pauseBusy={false} + onPriorityChange={() => undefined} + priorityUpdatingId={null} + switchingId={null} + pinnedId={null} + onReauth={() => undefined} + onEditAlias={() => undefined} + onRemove={() => undefined} + /> + , + ); + }); +} + +test("default priority selector is rendered once inside the open more-actions panel", async () => { + await mount(0); + const more = host.querySelector("details.codex-account-more"); + expect(more).not.toBeNull(); + expect(host.querySelectorAll(prioritySelector)).toHaveLength(0); + + await act(async () => { + more!.querySelector("summary")!.click(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + }); + + expect(more!.open).toBe(true); + expect(host.querySelectorAll(prioritySelector)).toHaveLength(1); + expect(more!.querySelector(prioritySelector)).not.toBeNull(); + expect(host.querySelector(`.codex-account-identity ${prioritySelector}`)).toBeNull(); +}); + +test("non-default priority selector is rendered once inline and out of the closed disclosure", async () => { + await mount(2); + const more = host.querySelector("details.codex-account-more"); + expect(more).not.toBeNull(); + expect(more!.open).toBe(false); + expect(host.querySelectorAll(prioritySelector)).toHaveLength(1); + expect(more!.querySelector(prioritySelector)).toBeNull(); + expect(host.querySelector(`.codex-account-identity ${prioritySelector}`)).not.toBeNull(); +});