diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index e365049467..95d2221463 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -394,14 +394,16 @@ continues to own selection, keyboard navigation, label overflow, and end-action anatomy. Product wrappers own document states and layout, without replacing the tab's control styles or creating another tablist. -`TabGroupItem.endAction` reserves space inside the tab surface. On pointer devices, -the icon, label, and non-interactive suffix are centered together at rest, then -shift toward the leading edge as the action fades in on hover. The tab and action -hit areas keep fixed positions and widths. Keyboard focus reveals the action and -moves the content immediately; touch keeps the action visible and the content -clear of it. Reduced motion disables both transitions. Tabs without an end action -keep their ordinary centered layout. Label overflow and rolling-text replacement -remain inside the label slot, independent of this movement. +`TabGroupItem.endAction` keeps the resting pointer-device capsule compact, with +symmetrical padding around its centered content. Hover grows the capsule at its +inline end, then fades in the action after expansion finishes. The icon, label +slot, and non-interactive suffix retain their positions and widths; following +tabs make room for the growing capsule. Keyboard focus expands and reveals the +action immediately; touch keeps it visible. Reduced motion makes both steps +immediate. Constrain the item's content width rather than a product wrapper's +outer width so the action can grow beyond a capped label. Tabs without an end +action keep their ordinary centered layout. Label overflow and rolling-text +replacement remain inside the label slot, independent of action visibility. ## Mobile controls diff --git a/design-system/packages/ui/src/components/TabGroup/TabGroup.meta.ts b/design-system/packages/ui/src/components/TabGroup/TabGroup.meta.ts index 7d609f1ac7..fdfd6696dc 100644 --- a/design-system/packages/ui/src/components/TabGroup/TabGroup.meta.ts +++ b/design-system/packages/ui/src/components/TabGroup/TabGroup.meta.ts @@ -2,7 +2,7 @@ import type { ComponentMeta } from "../../registry.types"; export const tabGroupMeta = { category: "navigation", - description: "A compact single-selection tab list with leading icons, a fixed internal slot for glass end actions, and automatic keyboard activation. Content is centered at rest and shifts aside when an action appears without resizing the tab.", + description: "A compact single-selection tab list with leading icons, glass end actions, and automatic keyboard activation. Content stays centered at rest; hover grows the capsule at its inline end before revealing the action, while the content stays in place.", maturity: "stable", name: "TabGroup", props: [ diff --git a/design-system/packages/ui/src/components/TabGroup/TabGroup.module.css b/design-system/packages/ui/src/components/TabGroup/TabGroup.module.css index 7e352e4e4d..2c64c64139 100644 --- a/design-system/packages/ui/src/components/TabGroup/TabGroup.module.css +++ b/design-system/packages/ui/src/components/TabGroup/TabGroup.module.css @@ -5,7 +5,6 @@ --_tab-group-item-padding-inline: var(--openbitfun-control-tab-group-item-padding-inline); --_tab-group-item-gap: var(--openbitfun-control-tab-group-item-gap); --_tab-group-item-icon-size: var(--openbitfun-control-tab-group-item-icon-size); - --_tab-group-inline-direction: 1; --_tab-group-action-padding: max( var(--_tab-group-item-padding-inline), calc((var(--_tab-group-item-height) + var(--openbitfun-control-tab-group-item-action-size)) / 2 + var(--_tab-group-item-gap)) @@ -24,11 +23,11 @@ --_tab-group-item-icon-size: var(--openbitfun-control-tab-group-item-icon-size-sm); } - .tabGroup:dir(rtl) { --_tab-group-inline-direction: -1; } - .item { --_floating-action-host-height: var(--_tab-group-item-height); --_floating-action-size: var(--openbitfun-control-tab-group-item-action-size); + box-sizing: content-box; + isolation: isolate; position: relative; display: inline-flex; flex: 0 0 auto; @@ -51,10 +50,11 @@ max-inline-size: 100%; padding-block: var(--_tab-group-item-padding-block); padding-inline: var(--_tab-group-item-padding-inline); - border: var(--openbitfun-border-width-default) solid var(--_tab-border); + border: var(--openbitfun-border-width-default) solid transparent; border-radius: var(--openbitfun-control-tab-group-item-radius); color: var(--_tab-content); - background: var(--_tab-background); + background: transparent; + outline: none; font-family: var(--openbitfun-type-label-md-font-family); font-size: var(--openbitfun-type-label-md-font-size); font-weight: var(--openbitfun-type-label-md-font-weight); @@ -62,10 +62,23 @@ line-height: var(--openbitfun-type-label-md-line-height); white-space: nowrap; cursor: pointer; + transition: color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + } + + /* The surface follows the item's growing action area; the tab content stays put. */ + .tab::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + box-sizing: border-box; + border: var(--openbitfun-border-width-default) solid var(--_tab-border); + border-radius: inherit; + background: var(--_tab-background); + pointer-events: none; transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), - border-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), - color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + border-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } .tab[aria-selected="true"] { @@ -76,21 +89,25 @@ font-weight: var(--openbitfun-type-label-selected-font-weight); } - .tab:hover:not(:disabled), - .item:hover .tab:not(:disabled), - .tabGroup[data-openbitfun-preview-state="hover"] .tab[aria-selected="false"]:not(:disabled) { + .tab:hover:not(:disabled)::before, + .item:hover .tab:not(:disabled)::before, + .tabGroup[data-openbitfun-preview-state="hover"] .tab[aria-selected="false"]:not(:disabled)::before { background: var(--_tab-background-hover); border-color: var(--openbitfun-component-button-outline-border-interactive); } - .tab:active:not(:disabled), - .tabGroup[data-openbitfun-preview-state="active"] .tab[aria-selected="false"]:not(:disabled) { + .tab:active:not(:disabled)::before, + .tabGroup[data-openbitfun-preview-state="active"] .tab[aria-selected="false"]:not(:disabled)::before { background: var(--_tab-background-active); border-color: var(--openbitfun-component-button-outline-border-interactive); + } + + .tab:active:not(:disabled), + .tabGroup[data-openbitfun-preview-state="active"] .tab[aria-selected="false"]:not(:disabled) { font-weight: var(--openbitfun-type-label-selected-font-weight); } - .tab:focus-visible { + .tab:focus-visible::before { outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); outline-offset: var(--openbitfun-focus-offset); } @@ -100,27 +117,61 @@ cursor: not-allowed; } - /* The action owns a fixed slot inside the tab; hover never resizes either hit area. */ - .item[data-has-end-action="true"] > .tab { - padding-inline-end: var(--_tab-group-action-padding); + /* Touch keeps the action exposed; desktop opens this area only on interaction. + Content-box padding lets capped labels keep their width while the pill grows. */ + .item[data-has-end-action="true"] { + padding-inline-end: calc(var(--_tab-group-action-padding) - var(--_tab-group-item-padding-inline)); } @media (hover: hover) and (pointer: fine) { - .item[data-has-end-action="true"] > .tab > :is(.icon, .label, .labelSuffix) { - transform: translateX(calc( - (var(--_tab-group-action-padding) - var(--_tab-group-item-padding-inline)) / 2 - * var(--_tab-group-inline-direction) - )); - transition: transform var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + .item[data-has-end-action="true"] { + padding-inline-end: 0; + transition: padding-inline-end var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + } + + .item[data-has-end-action="true"]:is(:hover, :focus-visible, :has(:focus-visible)), + .tabGroup:is([data-openbitfun-preview-state="hover"], [data-openbitfun-preview-state="focus-visible"]) .item[data-has-end-action="true"] { + padding-inline-end: calc(var(--_tab-group-action-padding) - var(--_tab-group-item-padding-inline)); + } + + .endAction { + visibility: hidden; + transition: visibility 0s; + } + + .item:hover > .endAction, + .tabGroup[data-openbitfun-preview-state="hover"] .endAction { + visibility: visible; + transition-delay: var(--openbitfun-motion-duration-fast); + } + + .item:hover > .endAction > button, + .tabGroup[data-openbitfun-preview-state="hover"] .endAction > button { + transition-delay: var(--openbitfun-motion-duration-fast), 0s, 0s; } - .item[data-has-end-action="true"]:is(:hover, :focus-visible, :has(:focus-visible)) > .tab > :is(.icon, .label, .labelSuffix) { - transform: none; + .tabGroup:is([data-openbitfun-preview-state="hover"], [data-openbitfun-preview-state="focus-visible"]) .endAction { + pointer-events: auto; + } + + .tabGroup:is([data-openbitfun-preview-state="hover"], [data-openbitfun-preview-state="focus-visible"]) .endAction > button { + opacity: 1; } } - .item[data-has-end-action="true"]:is(:focus-visible, :has(:focus-visible)) > .tab > :is(.icon, .label, .labelSuffix), - .item:is(:focus-visible, :has(:focus-visible)) > .endAction > button { + .item:is(:focus-visible, :has(:focus-visible)), + .tabGroup[data-openbitfun-preview-state="focus-visible"] .item { + transition: none; + } + + .item:is(:focus-visible, :has(:focus-visible)) > .endAction, + .tabGroup[data-openbitfun-preview-state="focus-visible"] .endAction { + visibility: visible; + transition: none; + } + + .item:is(:focus-visible, :has(:focus-visible)) > .endAction > button, + .tabGroup[data-openbitfun-preview-state="focus-visible"] .endAction > button { transition: none; } @@ -194,8 +245,9 @@ } @media (prefers-reduced-motion: reduce) { + .item[data-has-end-action="true"], .tab, - .item[data-has-end-action="true"] > .tab > :is(.icon, .label, .labelSuffix), + .tab::before, .endAction, .endAction > :where(button, [role="button"]):where(:not([data-openbitfun-component="icon-button"])) { transition: none; @@ -203,17 +255,20 @@ } @media (forced-colors: active) { - .tab { + .tab::before { border-color: ButtonText; } .tab[aria-selected="true"] { - border-color: Highlight; color: HighlightText; + } + + .tab[aria-selected="true"]::before { + border-color: Highlight; background: Highlight; } - .tab:focus-visible { + .tab:focus-visible::before { outline-color: Highlight; } diff --git a/design-system/packages/ui/tests/tab-group.test.mjs b/design-system/packages/ui/tests/tab-group.test.mjs index f33eb795ed..e532307b9d 100644 --- a/design-system/packages/ui/tests/tab-group.test.mjs +++ b/design-system/packages/ui/tests/tab-group.test.mjs @@ -167,14 +167,16 @@ test("end actions are rendered beside tabs instead of nesting interactive contro assert.equal((markup.match(/type="button"/g) ?? []).length, 3); }); -test("end actions reserve a fixed slot and move content without resizing hit areas", async () => { +test("end actions grow the capsule outside the centered content before appearing", async () => { const styles = await readFile(new URL("../src/components/TabGroup/TabGroup.module.css", import.meta.url), "utf8"); - assert.match(styles, /\.item\[data-has-end-action="true"\] > \.tab\s*\{\s*padding-inline-end:/); + assert.match(styles, /\.item\s*\{[^}]*box-sizing:\s*content-box;/s); + assert.match(styles, /\.tab\s*\{[^}]*padding-inline:\s*var\(--_tab-group-item-padding-inline\);/s); + assert.match(styles, /\.tab::before\s*\{[^}]*inset:\s*0;/s); + assert.match(styles, /\.item\[data-has-end-action="true"\]\s*\{\s*padding-inline-end:\s*0;/); + assert.match(styles, /transition:\s*padding-inline-end\s+var\(--openbitfun-motion-duration-fast\)/); + assert.match(styles, /\.item:hover > \.endAction,[^{]*\{[^}]*visibility:\s*visible;[^}]*transition-delay:\s*var\(--openbitfun-motion-duration-fast\);/s); assert.doesNotMatch(styles, /clip-path|mask-image/); - const hoverRules = [...styles.matchAll(/([^{}]*:hover[^{}]*)\{([^{}]*)\}/g)]; - for (const [, , declarations] of hoverRules) { - assert.doesNotMatch(declarations, /(?:padding|margin|(?:inline|block)-size|width|height)[\w-]*\s*:/); - } + assert.doesNotMatch(styles, /translateX/); }); test("TabGroup styling uses its geometry contract and Button semantic colors", async () => { diff --git a/scripts/i18n-contract.test.mjs b/scripts/i18n-contract.test.mjs index 93fbaf1d02..7e0c8d198e 100644 --- a/scripts/i18n-contract.test.mjs +++ b/scripts/i18n-contract.test.mjs @@ -217,7 +217,7 @@ test('web-ui runtime keeps locale namespaces lazy outside its bootstrap set', () const webI18nSource = readText('src/web-ui/src/infrastructure/i18n/core/I18nService.ts'); assert.match(webI18nSource, /WEB_UI_BOOTSTRAP_NAMESPACES/, 'Web UI should declare the small eager namespace set'); - assert.match(webI18nSource, /import\.meta\.glob\('\.\.\/\.\.\/\.\.\/locales\/\*\*\/\*\.json'/, 'Web UI should keep a lazy namespace glob for non-bootstrap resources'); + assert.match(webI18nSource, /import\.meta\.glob\(\[\s*'\.\.\/\.\.\/\.\.\/locales\/\*\*\/\*\.json'/, 'Web UI should keep a lazy namespace glob for non-bootstrap resources'); const lazyGlobBlock = webI18nSource.match(/const lazyLocaleModules = import\.meta\.glob\([\s\S]*?\) as/)?.[0] ?? ''; assert.doesNotMatch( lazyGlobBlock, diff --git a/src/apps/desktop/src/computer_use/provider_worker.rs b/src/apps/desktop/src/computer_use/provider_worker.rs index 2be5361a70..7428f0a9cc 100644 --- a/src/apps/desktop/src/computer_use/provider_worker.rs +++ b/src/apps/desktop/src/computer_use/provider_worker.rs @@ -90,16 +90,9 @@ impl Request { } fn abandon(&self, reason: &str) -> String { - let previous = self - .state - .fetch_update(Ordering::AcqRel, Ordering::Acquire, |state| { - Some(if state == INPUT_SUBMITTED || state == INPUT_UNKNOWN { - INPUT_UNKNOWN - } else { - ABANDONED - }) - }) - .unwrap(); + // The abandonment bit maps READING to ABANDONED and INPUT_SUBMITTED + // to INPUT_UNKNOWN, preserving both terminal states on repeated calls. + let previous = self.state.fetch_or(ABANDONED, Ordering::AcqRel); self.failure_message( reason, previous == INPUT_SUBMITTED || previous == INPUT_UNKNOWN, diff --git a/src/crates/adapters/ai-adapters/src/subscription_auth/store.rs b/src/crates/adapters/ai-adapters/src/subscription_auth/store.rs index 1fbf918af7..3554b0bb0e 100644 --- a/src/crates/adapters/ai-adapters/src/subscription_auth/store.rs +++ b/src/crates/adapters/ai-adapters/src/subscription_auth/store.rs @@ -1848,6 +1848,7 @@ async fn upsert_internal( } /// Removes one provider from both the credential vault and metadata index. +#[cfg(test)] pub(crate) async fn remove(provider: &str) -> Result { remove_internal(provider, false).await } diff --git a/src/crates/assembly/core/src/agentic/execution/round_model_routing/admission.rs b/src/crates/assembly/core/src/agentic/execution/round_model_routing/admission.rs index 0896300533..eb93096cb0 100644 --- a/src/crates/assembly/core/src/agentic/execution/round_model_routing/admission.rs +++ b/src/crates/assembly/core/src/agentic/execution/round_model_routing/admission.rs @@ -98,8 +98,8 @@ impl RouterAdmission { Ok(gate) } - /// Compatibility for callers without a task hierarchy, including old probes. - /// Each anonymous request is an independent fair-share participant. + /// Give each anonymous test request an independent fair-share participant. + #[cfg(test)] pub(super) async fn acquire( self: &Arc, deadline: Instant, diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index 15ead76cbc..c3c3716fe1 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -4377,6 +4377,7 @@ impl SessionManager { .map(|_| ()) } + #[cfg(test)] pub(crate) async fn update_session_model_selection_in_storage( &self, storage_path: Option<&Path>, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs index b40f5227a2..0df9a1d501 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs @@ -222,16 +222,20 @@ impl ExecCommandTool { } fn command_env() -> HashMap { - let mut environment = exec_command_noninteractive_env(); + let environment = exec_command_noninteractive_env(); // The terminal executor applies this map on top of the host // environment, which a Linux GUI or `.desktop` launch starts without a // login shell's PATH. Setting it here is what lets the shell resolve a // toolchain-managed `node`/`bun` the way a terminal does. // Windows and macOS keep the inherited environment unchanged. #[cfg(target_os = "linux")] - if let Some(path) = openbitfun_services_core::process_manager::platform_path_env() { - environment.insert("PATH".to_string(), path.to_string_lossy().into_owned()); - } + let environment = { + let mut environment = environment; + if let Some(path) = openbitfun_services_core::process_manager::platform_path_env() { + environment.insert("PATH".to_string(), path.to_string_lossy().into_owned()); + } + environment + }; environment } diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs index c5fe70cd39..1aa4b21809 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -134,6 +134,7 @@ fn local_plugin_workspace_scope(workspace: &WorkspaceBinding) -> Option /// Convert framework::ToolResult to core::ToolResult /// /// Ensure always has result_for_assistant, avoid tool message content being empty +#[cfg(test)] fn convert_tool_result( framework_result: FrameworkToolResult, tool_id: &str, diff --git a/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs b/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs index 7e6e99063b..fad63a7c66 100644 --- a/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs +++ b/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs @@ -597,6 +597,7 @@ impl AIClientFactory { }) } + #[cfg(test)] pub(crate) async fn account_client_source( self: &Arc, model_id: &str, diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.scss b/src/web-ui/src/app/components/SceneBar/SceneBar.scss index 368f875770..09bbcbdb05 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.scss +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.scss @@ -174,7 +174,7 @@ } &__tabs [data-openbitfun-part='item'] { - // Cap the whole tab, including its label padding and close-action reserve. + // Cap the resting capsule; TabGroup grows its action area beyond this width. max-inline-size: 220px; } diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.scss b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.scss index 53e19b23b1..123a461eca 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.scss +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.scss @@ -2,12 +2,12 @@ .canvas-tab { display: flex; min-inline-size: 80px; - max-inline-size: 240px; position: relative; user-select: none; > [data-openbitfun-part='item'] { - max-inline-size: 100%; + // Let the capsule grow its action area without compressing a capped label. + max-inline-size: 240px; } &.is-preview:not(.is-task-detail) &__title { diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.loading.test.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.loading.test.tsx index 21f13d0764..45c2b4d9d7 100644 --- a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.loading.test.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.loading.test.tsx @@ -94,6 +94,7 @@ describe('ModelSettingsPage loading and provider actions', () => { it.each([false, true])('shows host accounts on the page without importing a model (account slots: %s)', async slotsSupported => { const account = { provider: 'codex' as const, connected: true, account: 'shared@example.invalid', + expires_at: 1800000000, display_label: 'Codex', suggested_format: 'openai', suggested_base_url: 'https://example.test/v1', suggested_model: 'fixture-model', api_offerings: [], }; @@ -102,6 +103,14 @@ describe('ModelSettingsPage loading and provider actions', () => { { account_id: 'codex', is_default: true, account, enabled: true }, { account_id: 'codex:second-slot', is_default: false, account, enabled: false }, ] : []); + const quotaResponse = { + provider: 'codex' as const, account_id: 'codex', state: 'known' as const, plan: 'Pro', valid_until: Date.now() / 1000 + 60, + windows: [{ id: 'weekly', duration_seconds: 604800, used_percent: 25 }], + }; + const pendingQuota = deferred(); + const quotaRead = vi.spyOn(aiApi, 'getSubscriptionAccountQuota') + .mockImplementationOnce(() => pendingQuota.promise as never).mockResolvedValue(quotaResponse); + const refreshAccount = vi.spyOn(aiApi, 'refreshSubscriptionAccount').mockResolvedValue(account); const update = vi.spyOn(configManager, 'updateConfig'); await act(async () => root.render()); const summary = container.querySelector('[data-testid="settings-subscription-services"]')!; @@ -113,7 +122,43 @@ describe('ModelSettingsPage loading and provider actions', () => { expect(document.querySelector('[role="dialog"]')).toBeNull(); expect(update).not.toHaveBeenCalled(); await act(async () => summary.querySelector('button')!.click()); - expect(document.querySelector('[role="dialog"]')?.textContent).toContain('shared@example.invalid'); + const dialog = document.querySelector('[role="dialog"]')!; + expect(dialog.textContent).toContain('shared@example.invalid'); + const quota = dialog.querySelector('[data-account-id="codex"] [data-openbitfun-part="subscriptionQuota"]')!; + expect(quota.closest('.openbitfun-config-page-row__description')).not.toBeNull(); + expect(quota.querySelector('[data-openbitfun-component="card"]')).toBeNull(); + expect(quota.querySelector('[data-testid="subscription-quota-skeleton"]')).not.toBeNull(); + expect(quota.querySelector('[role="status"]')?.classList.contains('sr-only')).toBe(true); + const heading = dialog.querySelector('[data-account-id="codex"] .openbitfun-config-page-row__label')!; + expect(heading.querySelector('[aria-hidden="true"]')).not.toBeNull(); + expect(quotaRead).toHaveBeenCalledTimes(1); + expect(dialog.textContent).not.toContain(copy('subscriptionAuth.pool.defaultAccount')); + const expiry = dialog.querySelector('[data-account-id="codex"] .openbitfun-model-settings__cli-authorization-expiry')!; + expect(expiry.parentElement).toBe(quota.parentElement); + expect(expiry.getAttribute('datetime')).toBe(new Date(account.expires_at * 1000).toISOString()); + expect(expiry.textContent).not.toContain(i18nService.formatDate(new Date(account.expires_at * 1000), { dateStyle: 'medium', timeStyle: 'short' })); + expect(quota.querySelector(`button[aria-label="${copy('quota.refresh')}"]`)).toBeNull(); + const refresh = [...quota.closest('.openbitfun-model-settings__cli-account')!.querySelectorAll('button')] + .find(element => element.textContent === copy('subscriptionAuth.refresh'))!; + expect(refresh.disabled).toBe(true); + await act(async () => pendingQuota.resolve(quotaResponse)); + expect(quota.querySelector('[data-testid="subscription-quota-skeleton"]')).toBeNull(); + expect(quota.querySelectorAll('[data-filled="true"]')).toHaveLength(18); + expect(heading.querySelector('[data-openbitfun-component="status-pill"]')?.textContent).toBe('Pro'); + expect(quota.querySelector('[data-openbitfun-component="status-pill"]')).toBeNull(); + expect(expiry.parentElement).toBe(quota.parentElement); + expect(refresh.disabled).toBe(false); + const refreshedQuota = deferred(); + quotaRead.mockImplementationOnce(() => refreshedQuota.promise as never); + await act(async () => refresh.click()); + expect(refreshAccount).toHaveBeenCalledExactlyOnceWith('codex'); + expect(quotaRead).toHaveBeenLastCalledWith('codex', 'codex', true); + expect(refresh.disabled).toBe(true); + await act(async () => refreshedQuota.resolve({ ...quotaResponse, + windows: [{ id: 'weekly', duration_seconds: 604800, used_percent: 50 }], + })); + expect(quota.querySelector('[role="progressbar"]')?.getAttribute('aria-valuenow')).toBe('50'); + expect(refresh.disabled).toBe(false); expect(update).not.toHaveBeenCalled(); }); diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.scss b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.scss index 7931bb3abb..0eeb5dc5e1 100644 --- a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.scss +++ b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.scss @@ -2455,10 +2455,6 @@ min-inline-size: 0; } - &__cli-discovery [data-openbitfun-part='subscriptionQuota'] { - margin-inline: var(--openbitfun-layout-card-padding-md); - } - &__cli-empty { padding: var(--openbitfun-space-4); @@ -2473,13 +2469,65 @@ &__cli-account.openbitfun-config-page-row { grid-template-columns: minmax(0, 1fr) auto; gap: var(--openbitfun-space-4); + row-gap: var(--openbitfun-space-2); padding: var(--openbitfun-layout-card-padding-md); + + > .openbitfun-config-page-row__meta { display: contents; } + .openbitfun-config-page-row__label { grid-column: 1; grid-row: 1; } + .openbitfun-config-page-row__control { grid-column: 2; grid-row: 1; } + .openbitfun-config-page-row__description { + grid-column: 1 / -1; + grid-row: 2; + display: grid; + gap: var(--openbitfun-space-1); + max-inline-size: none; + margin: 0; + } + + &:hover { + background: transparent; + } } &__cli-description-line { display: block; } + &__cli-account-heading { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--openbitfun-space-2); + } + + &__cli-account-summary { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--openbitfun-space-2) var(--openbitfun-space-4); + min-inline-size: 0; + + > [data-openbitfun-part='subscriptionQuota'] { + flex: 1 1 240px; + min-inline-size: 0; + } + } + + &__cli-authorization-expiry { + margin-inline-start: auto; + color: var(--openbitfun-color-content-muted); + font-size: var(--openbitfun-type-meta-font-size); + text-align: end; + font-variant-numeric: tabular-nums; + + &[data-expired='true'] { color: var(--openbitfun-color-status-danger-content); } + &:focus-visible { + outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); + outline-offset: var(--openbitfun-focus-offset); + border-radius: var(--openbitfun-radius-sm); + } + } + &__cli-actions { display: inline-flex; align-items: center; @@ -2610,6 +2658,8 @@ gap: var(--openbitfun-space-2); .openbitfun-config-page-row__control { + grid-column: 1; + grid-row: 3; justify-content: flex-start; } } diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx index 62d30f8eb7..1231409af4 100644 --- a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx @@ -6,6 +6,7 @@ import { ModelPoolCardActions } from './ModelPoolCardActions'; import { ZcodeSubscriptionGroup } from './ZcodeSubscriptionGroup'; import { OpenCodeModelQuota } from './OpenCodeModelQuota'; import { SubscriptionQuotaPanel } from './SubscriptionQuotaPanel'; +import { SubscriptionAuthorizationExpiry } from './SubscriptionAuthorizationExpiry'; import { SubscriptionAccountList, SubscriptionServiceGroup } from './SubscriptionServiceGroup'; import { managedSubscriptionAccounts, modelsUsingSubscriptionAccount, groupSubscriptionServices, isRetiredSubscriptionProvider, isZcodeProvider, preferredZcodeProvider, subscriptionManagerAccounts } from './subscriptionServices'; import { aiApi, systemAPI } from '@/infrastructure/api'; @@ -4082,21 +4083,16 @@ const ModelSettingsPage: React.FC = () => { }; const renderAccount = (account: SubscriptionAccount, groupedRegion = false, slot: string = account.provider, enabled = true) => { const retiredAccount = isRetiredSubscriptionProvider(account.provider); - const descriptionParts: string[] = [slot === account.provider ? t('subscriptionAuth.pool.defaultAccount') : t('subscriptionAuth.accountMarker', { marker: slot.slice(-8) })]; + const showQuota = account.connected && enabled && !account.vault_unavailable && !retiredAccount; + const authorizationExpiry = !retiredAccount && account.connected && account.expires_at + ? account.expires_at + : null; + const descriptionParts: string[] = slot === account.provider ? [] : [t('subscriptionAuth.accountMarker', { marker: slot.slice(-8) })]; if (!enabled) descriptionParts.push(t('subscriptionAuth.pool.disabled')); if (retiredAccount) { descriptionParts.push(t('subscriptionAuth.retiredProvider')); - } else if (account.connected && account.expires_at) { - descriptionParts.push( - t('subscriptionAuth.expiresAt', { - time: i18nService.formatDate(new Date(account.expires_at * 1000), { - dateStyle: 'medium', - timeStyle: 'short', - }), - }), - ); } else if (account.connected) { - descriptionParts.push(t('subscriptionAuth.tokenValid')); + if (!authorizationExpiry) descriptionParts.push(t('subscriptionAuth.tokenValid')); } else if (account.vault_unavailable) { descriptionParts.push(t('subscriptionAuth.vaultUnavailable')); } else if (account.reauthentication_required) { @@ -4114,18 +4110,23 @@ const ModelSettingsPage: React.FC = () => { ? Math.max(0, Math.ceil((loginPanel.deadlineMs - subscriptionLoginClock) / 1000)) : 0; const countdown = `${Math.floor(remainingSeconds / 60)}:${String(remainingSeconds % 60).padStart(2, '0')}`; - return ( + const renderRow = (plan: React.ReactNode = null, quota: React.ReactNode = null, quotaLoading = false) => ( ( - - {part} - - ))} + label={ + {`${groupedRegion ? `${subscriptionRegionName(account.provider)} · ` : ''}${account.account || t(loginPanel ? 'subscriptionAuth.newAccount' : 'subscriptionAuth.accountIdentityUnavailable')}`} + {plan} + } + description={<> + {(showQuota || authorizationExpiry) && ( +
+ {quota} + {authorizationExpiry && } +
+ )} + {descriptionParts.length > 0 && {descriptionParts.join(' · ')}} + } className="openbitfun-model-settings__cli-account" align="center" > @@ -4136,8 +4137,8 @@ const ModelSettingsPage: React.FC = () => { } - {!query.unsupportedHost && snapshot?.state !== 'unsupported' && ( + {!(inline && renderInline) && !query.unsupportedHost && snapshot?.state !== 'unsupported' && ( - {status &&
{!initialLoading && } {status}
} - {initialLoading &&