fix(ui): prevent responsive panels from clipping content

This commit is contained in:
jesieleo 2026-08-02 12:46:10 +08:00
parent 4a152d959c
commit 440241faa0
5 changed files with 94 additions and 13 deletions

View file

@ -2374,7 +2374,14 @@ function ProviderAccountsOverview({
})}
</div>
) : (
<div className={cn("grid h-full min-h-0 grid-cols-1 overflow-y-auto pr-1", providerAccountGapClass(dimensions), providerAccountGridClass(dimensions, visibleAccounts.length))}>
<div
className={cn(
"grid h-full min-h-0 auto-rows-max content-start grid-cols-1 overflow-y-auto pb-2 pr-2 [scrollbar-gutter:stable]",
providerAccountGapClass(dimensions),
providerAccountGridClass(dimensions, visibleAccounts.length)
)}
data-provider-account-grid="true"
>
{visibleAccounts.map((account) => {
return <ProviderAccountSummaryCard account={account} dimensions={dimensions} key={providerAccountSnapshotKey(account)} refreshing={refreshing} variant={variant} onRefresh={onRefresh} />;
})}
@ -2406,7 +2413,7 @@ function ProviderAccountSinglePanel({
return (
<div className={cn("flex h-full min-h-0 min-w-0 flex-col overflow-hidden", providerAccountStackClass(dimensions))}>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="flex min-w-0 shrink-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className={cn("truncate font-semibold", dimensions.height <= 1 ? "text-[12px]" : "text-[13px]")}>{providerAccountSnapshotLabel(account)}</div>
{providerAccountShowRefreshTime(dimensions) ? <div className="mt-0.5 truncate text-[11px] text-muted-foreground">{formatProviderAccountRefreshTime(account, t)}</div> : null}
@ -2453,7 +2460,7 @@ function ProviderAccountSummaryCard({
const showQuotaVisual = providerAccountUsesQuotaVisual(variant) && quotaMeters.length > 0;
return (
<div className={cn("overview-nested-surface min-h-0 min-w-0 overflow-hidden border", providerAccountCardPaddingClass(dimensions))}>
<div className={cn("overview-nested-surface h-fit min-w-0 self-start overflow-hidden border", providerAccountCardPaddingClass(dimensions))}>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-[13px] font-semibold">{providerAccountSnapshotLabel(account)}</div>
@ -2470,7 +2477,7 @@ function ProviderAccountSummaryCard({
<ProviderAccountBalanceMetric dimensions={dimensions} meter={balanceMeter} compact />
</div>
) : meters.length > 0 ? (
<div className={cn("mt-2 min-h-0 overflow-hidden", providerAccountStackClass(dimensions))}>
<div className={cn("mt-2 min-h-0", providerAccountStackClass(dimensions))}>
{meters.map((meter) => (
<ProviderAccountMeterLine account={account} dimensions={dimensions} key={meter.id} meter={meter} onRefresh={onRefresh} />
))}
@ -2563,7 +2570,7 @@ function ProviderAccountMeterLine({
) : null}
<div className={titleClassName}>{title}</div>
</div>
<div className={valueClassName}>{formatProviderAccountMeterValue(meter)}</div>
<div className={valueClassName}>{formatProviderAccountMeterValue(meter, t)}</div>
</>
);

View file

@ -195,7 +195,7 @@ export function MainLayout({
}}
aria-hidden={!sidebarOpen}
className={cn(
"app-sidebar flex shrink-0 flex-col overflow-hidden bg-sidebar/95 max-[720px]:h-auto",
"app-sidebar flex min-h-0 shrink-0 flex-col overflow-hidden bg-sidebar/95 max-[720px]:h-auto",
sidebarOpen && compactLayout && "border-b border-border"
)}
id="primary-sidebar"
@ -217,7 +217,7 @@ export function MainLayout({
<div className="app-drag min-w-0 flex-1" />
</div>
<nav className="flex min-h-0 flex-1 flex-col gap-4 px-2 py-3 max-[720px]:flex-none max-[720px]:flex-row max-[720px]:gap-1 max-[720px]:overflow-x-auto max-[720px]:py-2" aria-label={copy.sidebar.primaryNavigation}>
<nav className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-2 py-3 max-[720px]:flex-none max-[720px]:flex-row max-[720px]:gap-1 max-[720px]:overflow-x-auto max-[720px]:overflow-y-hidden max-[720px]:py-2" aria-label={copy.sidebar.primaryNavigation}>
{navigationGroups.map((group) => (
<div className="grid min-w-0 gap-1 max-[720px]:contents" key={group.id}>
<div className="px-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/65 max-[720px]:hidden">

View file

@ -145,7 +145,10 @@ export function providerAccountProgressClass(status: ProviderAccountSnapshot["st
return "bg-emerald-500";
}
export function formatProviderAccountMeterValue(meter: ProviderAccountMeter): string {
export function formatProviderAccountMeterValue(
meter: ProviderAccountMeter,
translate: (value: string) => string = (value) => value
): string {
const value = meter.remaining ?? meter.used ?? meter.limit;
if (value === undefined) {
return "-";
@ -170,10 +173,11 @@ export function formatProviderAccountMeterValue(meter: ProviderAccountMeter): st
if (unit === "minutes") {
return `${formatProviderAccountNumber(value)}m`;
}
const displayUnit = translate(unit);
if (meter.kind === "balance") {
return `${formatProviderAccountNumber(value)} ${unit}`;
return `${formatProviderAccountNumber(value)} ${displayUnit}`;
}
return `${formatCompactNumber(value)} ${unit}`;
return `${formatCompactNumber(value)} ${displayUnit}`;
}
export function formatProviderAccountNumber(value: number): string {

View file

@ -2,12 +2,12 @@ import assert from "node:assert/strict";
import test from "node:test";
import * as React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { GatewayStartupErrorBanner, groupSidebarNavigation, UpdateEntryButton } from "@ccr/ui/pages/home/components/layout.tsx";
import { GatewayStartupErrorBanner, groupSidebarNavigation, MainLayout, UpdateEntryButton } from "@ccr/ui/pages/home/components/layout.tsx";
import { MediaModelConfigurationPanel, VirtualModelsView } from "@ccr/ui/pages/home/components/virtual-models.tsx";
import { AppI18nContext, appCopy } from "@ccr/ui/pages/home/shared/i18n.tsx";
import { createVirtualModelDraft } from "@ccr/ui/pages/home/shared/virtual-models.ts";
import { appConfigFixture } from "../fixtures/index.ts";
import { fallbackUpdateStatus } from "@ccr/ui/pages/home/shared/fallbacks.ts";
import { fallbackGatewayStatus, fallbackUpdateStatus } from "@ccr/ui/pages/home/shared/fallbacks.ts";
import { navigation } from "@ccr/ui/pages/home/shared/options.ts";
import { shouldCheckForUpdateOnOpen } from "@ccr/ui/pages/home/components/update.tsx";
@ -32,6 +32,41 @@ test("sidebar navigation groups pages and hides networking from the sidebar", ()
]);
});
test("sidebar navigation scrolls vertically without displacing the settings footer", () => {
const html = renderToStaticMarkup(
<MainLayout
activeView="networking"
agentAnalysisEnabled={false}
compactLayout={false}
config={appConfigFixture()}
copy={appCopy.en}
gatewayActionBusy={false}
gatewayEndpoint="http://127.0.0.1:3456"
gatewayStatus={fallbackGatewayStatus}
isMac={false}
needsTrafficLightSafeArea={false}
networkCaptureEnabled={false}
onOpenServerSettings={() => undefined}
onOpenSettings={() => undefined}
onOpenUpdate={() => undefined}
onSelectNavigationItem={() => undefined}
onToggleSidebar={() => undefined}
requestLogsEnabled={false}
shouldReduceMotion={true}
sidebarOpen
toggleGatewayService={() => undefined}
updateActionBusy={false}
updateStatus={fallbackUpdateStatus}
viewProps={{} as never}
visibleNavigation={navigation}
/>
);
assert.match(html, /<nav class="[^"]*overflow-y-auto[^"]*max-\[720px\]:overflow-y-hidden[^"]*"/);
assert.match(html, /class="grid shrink-0 gap-1 border-t/);
assert.ok(html.indexOf("</nav>") < html.indexOf("Settings"));
});
test("GatewayStartupErrorBanner renders startup failure details", () => {
const html = renderToStaticMarkup(
<AppI18nContext.Provider value={appCopy.zh}>

View file

@ -5,7 +5,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { formatCodexResetCardExpiry, formatCodexResetCardNumber, OverviewView } from "@ccr/ui/pages/home/components/dashboard.tsx";
import { AppI18nContext, appCopy } from "@ccr/ui/pages/home/shared/i18n.tsx";
import { parseStatusBucketDate } from "@ccr/ui/pages/home/shared/controls.tsx";
import { providerAccountMeterDetailValidityProgress } from "@ccr/ui/pages/home/shared/provider-accounts.ts";
import { formatProviderAccountMeterValue, providerAccountMeterDetailValidityProgress } from "@ccr/ui/pages/home/shared/provider-accounts.ts";
import type { OverviewWidgetConfig, ProviderAccountSnapshot } from "@ccr/core/contracts/app.ts";
import { accountSnapshots, installBrowserGlobals, usageStats } from "../fixtures/index.ts";
@ -143,6 +143,26 @@ test("OverviewView renders the empty widget layout state", () => {
assert.match(html, /aria-label="Edit widgets"/);
});
test("OverviewView keeps multi-provider account cards at their content height", () => {
const html = renderToStaticMarkup(
<OverviewView
overviewWidgets={[{ enabled: true, id: "account", size: "4:2", type: "account-balance", variant: "cards" }]}
providerAccounts={accountSnapshots()}
refreshProviderAccounts={() => undefined}
setUsageRange={() => undefined}
usageRange="30d"
usageStats={usageStats("30d")}
onWidgetsChange={() => undefined}
/>
);
assert.match(html, /data-provider-account-grid="true"/);
assert.match(html, /auto-rows-max/);
assert.match(html, /content-start/);
assert.match(html, /scrollbar-gutter:stable/);
assert.match(html, /h-fit/);
});
test("OverviewView prioritizes Codex manual resets before folded balance meters", () => {
const resetAt = new Date(Date.now() + 72 * 60 * 60 * 1000).toISOString();
const resetEffectiveAt = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
@ -282,6 +302,21 @@ test("OverviewView does not render an outer progress bar for Codex manual resets
assert.doesNotMatch(html, /Full reset/);
});
test("provider account meter values localize textual units", () => {
const value = formatProviderAccountMeterValue(
{
id: "codex_manual_resets",
kind: "requests",
label: "Manual resets",
remaining: 0,
unit: "resets"
},
(unit) => appCopy.zh.text[unit] ?? unit
);
assert.equal(value, `0 ${appCopy.zh.text.resets}`);
});
test("provider account reset credit detail progress uses each validity window", () => {
const effectiveAt = "2026-07-01T00:00:00.000Z";
const expiresAt = "2026-07-11T00:00:00.000Z";