feat: 阈值预警/加力钱包/api_key_env/版本跟踪器/一键体检 — bump to v0.7.20
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s

This commit is contained in:
KimiSwitch Dev committed 2026-09-20 09:49:49 +08:00
1 parent 35581103ed
commit fcd08eae32
35 files changed
+2567 -91

No files matched your search

+27
View File
@@ -78,6 +78,10 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
update({ background: { ...settings.background, ...patch } });
};
const updateWatch = (patch: Partial<AgentSettings["watch"]>) => {
update({ watch: { ...settings.watch, ...patch } });
};
const setRules = (rules: PermissionRule[]) => {
update({ permission: { rules } });
};
@@ -141,6 +145,15 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
value={settings.loop_control?.reserved_context_size ?? 50000}
onChange={(v) => updateLoopControl({ reserved_context_size: v })}
/>
{/* kimi-code 0.43.0+ `loop_control.compaction_max_attempts`. The
upstream default is 5; an empty field or 0 removes the key instead
of writing a literal 0 (see setAgentSettings). */}
<NumberField
label={t("compactionMaxAttempts")}
value={settings.loop_control?.compaction_max_attempts ?? 5}
onChange={(v) => updateLoopControl({ compaction_max_attempts: v })}
/>
<p className="text-xs text-content-muted">{t("compactionMaxAttemptsHint")}</p>
</Card>
<Card title={t("backgroundSettings")}>
@@ -156,6 +169,20 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
/>
</Card>
<Card title={t("watchSettings")}>
{/* Top-level `[watch] enabled` (kimi-code 2.0.1+; off by default since
2.0.2). KIMI_CODE_WATCH outranks this config at runtime — the env
var is probed by get_experimental_env_status as a non-flag entry
but deliberately locks nothing here, the toggle just documents the
config value that applies when the env var is unset. */}
<Checkbox
label={t("watchEnabled")}
checked={settings.watch?.enabled ?? false}
onChange={(checked) => updateWatch({ enabled: checked })}
/>
<p className="text-xs text-content-muted">{t("watchEnabledDesc")}</p>
</Card>
<Card title={t("permissionRules")}>
{/* kimi-code `[permission] dangerous_command_guard`. The env
var KIMI_CODE_DANGEROUS_COMMAND_GUARD (literal "true"/"false")
+74 -14
View File
@@ -11,6 +11,7 @@ import { AgentSettingsPanel } from "./AgentSettingsPanel";
import { KimiOAuthDialog } from "./KimiOAuthDialog";
import { ProviderIcon } from "./ProviderIcon";
import { IconPicker } from "./IconPicker";
import { Segmented } from "./ui/controls";
import type { Agent, DiscoveredModel, Model, Provider, ProviderType } from "../types";
const KNOWN_CAPABILITIES = [
@@ -127,6 +128,19 @@ export function ProviderEdit({
// Preset this provider was created from (if any) — provides the
// "Get API Key" / referral links shown under the key input.
const preset = findPresetForProvider(provider);
/**
* Credential source. `api_key_env` (kimi-code 2.0.0+) names an environment
* variable instead of storing the key in config.toml; the two are mutually
* exclusive upstream, so the UI is a mode switch and each mode clears the
* other field. Derived from the provider itself (no local state) so the
* switch follows provider switches and reloads; an empty-but-present value
* ("env mode picked, name not typed yet") still reads as env mode.
* Only Kimi Code understands `api_key_env` — Pi's provider file has no
* equivalent, so the switch and the env input are both gated on the agent
* (a Pi provider that somehow carries the key keeps its plain key input).
*/
const apiKeyEnvSupported = agent === "kimi_code";
const apiKeyEnvMode = apiKeyEnvSupported && provider.api_key_env != null;
useEffect(() => {
const def = defaultBaseUrl(agent, provider.provider_type);
@@ -322,27 +336,73 @@ export function ProviderEdit({
{!provider.managed && (
<div>
{apiKeyEnvSupported && (
<div className="flex items-center gap-3 mb-2">
<span className="text-sm text-content-muted">
{t("apiKeySource")}
</span>
<Segmented
options={[
{ key: "direct", label: t("apiKeySourceDirect") },
{ key: "env", label: t("apiKeySourceEnv") },
]}
value={apiKeyEnvMode ? "env" : "direct"}
onChange={(mode) => {
// Mutual exclusion: picking a source clears the
// other one so export never writes both keys.
if (mode === "env") {
onChange({
...provider,
api_key: null,
api_key_env: provider.api_key_env ?? "",
});
} else {
onChange({ ...provider, api_key_env: null });
}
}}
/>
</div>
)}
<label htmlFor={apiKeyId} className="block text-sm text-content-muted mb-1.5">
{t("apiKey")} <span className="text-red-500">*</span>
{apiKeyEnvMode ? t("apiKeyEnvName") : t("apiKey")}{" "}
<span className="text-red-500">*</span>
</label>
<div className="relative">
{apiKeyEnvMode ? (
<input
id={apiKeyId}
type={showApiKey ? "text" : "password"}
className="w-full bg-input border border-border rounded-lg px-3 py-2 pr-10 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.api_key || ""}
type="text"
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm font-mono focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.api_key_env ?? ""}
placeholder="KIMI_API_KEY"
onChange={(e) =>
onChange({ ...provider, api_key: e.target.value || null })
onChange({ ...provider, api_key_env: e.target.value })
}
/>
<button
type="button"
onClick={() => setShowApiKey((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-content-muted hover:text-content-primary text-xs px-1"
>
{showApiKey ? t("hide") : t("show")}
</button>
</div>
) : (
<div className="relative">
<input
id={apiKeyId}
type={showApiKey ? "text" : "password"}
className="w-full bg-input border border-border rounded-lg px-3 py-2 pr-10 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.api_key || ""}
onChange={(e) =>
onChange({ ...provider, api_key: e.target.value || null })
}
/>
<button
type="button"
onClick={() => setShowApiKey((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-content-muted hover:text-content-primary text-xs px-1"
>
{showApiKey ? t("hide") : t("show")}
</button>
</div>
)}
{apiKeyEnvMode && (
<p className="mt-1.5 text-xs text-content-muted">
{t("apiKeyEnvHint")}
</p>
)}
{(() => {
const linkUrl = preset?.apiKeyUrl ?? provider.official_url ?? null;
if (!linkUrl) return null;
+133 -23
View File
@@ -1,10 +1,26 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { Pencil, Copy, Activity, Loader2, Trash2, BarChart3 } from "lucide-react";
import {
Pencil,
Copy,
Activity,
Loader2,
Trash2,
BarChart3,
Stethoscope,
} from "lucide-react";
import { useTranslation } from "../i18n";
import { ProviderIcon } from "./ProviderIcon";
import { UsageFooter } from "./UsageFooter";
import { useUsageQuery } from "../hooks/useUsageQuery";
import {
useHealthCheck,
localizeHealthError,
probeLine,
usageLine,
healthDotClass,
type HealthResult,
} from "../hooks/useHealthCheck";
import type { Agent, Model, Provider } from "../types";
interface ConnectivityResult {
@@ -40,6 +56,12 @@ interface ProviderCardProps {
models: Record<string, Model>;
/** Inline connectivity-test state for this provider, or undefined when idle. */
ts: TestState | undefined;
/** 一键体检结论;undefined = 本轮未出结果(检测中或未检测)。 */
health: HealthResult | undefined;
/** 体检请求进行中。 */
healthRunning: boolean;
/** 体检至少跑过一轮(决定是否渲染灰色「未检测」点)。 */
healthRan: boolean;
onEdit: (name: string) => void;
onDelete: (name: string) => void;
onDuplicate: (name: string) => void;
@@ -54,6 +76,9 @@ function ProviderCard({
defaultModel,
models,
ts,
health,
healthRunning,
healthRan,
onEdit,
onDelete,
onDuplicate,
@@ -70,7 +95,8 @@ function ProviderCard({
provider.name,
provider.usageKinds,
provider.usageConfig?.autoQueryIntervalMinutes,
provider.usageConfig?.enabled === false
provider.usageConfig?.enabled === false,
provider.usageConfig?.threshold
);
const providerModels = Object.values(models).filter(
(m) => m.provider === provider.name
@@ -80,6 +106,17 @@ function ProviderCard({
: null;
const isActive = provider.active === true;
// 一键体检状态点 tooltip:两项检测各自如实展示——探活可能是「不支持模型
// 列表」(中性),账单可能是「失败: xxx」,都不掩盖。
const healthTip = health
? [
health.probe ? probeLine(health.probe, health.latencyMs, t) : null,
health.usage ? usageLine(health.usage, t) : null,
]
.filter((s): s is string => s !== null)
.join(" · ")
: "";
return (
<div
className={`group relative flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer w-full ${
@@ -102,6 +139,34 @@ function ProviderCard({
<h3 className="font-semibold text-content-primary truncate">
{provider.name}
</h3>
{/* 一键体检状态点:绿=正常、红=失败、灰=未判定(未检测 / 检测中) */}
{health ? (
<span
role="img"
className={`w-2.5 h-2.5 shrink-0 rounded-full ${healthDotClass(
health.verdict
)}`}
title={healthTip}
aria-label={healthTip}
/>
) : healthRunning ? (
<span
className="shrink-0 flex items-center"
title={t("healthCheckRunning")}
aria-label={t("healthCheckRunning")}
>
<Loader2 className="w-3.5 h-3.5 animate-spin text-content-muted" />
</span>
) : healthRan ? (
<span
role="img"
className={`w-2.5 h-2.5 shrink-0 rounded-full ${healthDotClass(
"neutral"
)}`}
title={t("healthNotChecked")}
aria-label={t("healthNotChecked")}
/>
) : null}
{isActive && (
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border border-green-300 dark:border-green-500/30">
{t("inUse")}
@@ -273,6 +338,7 @@ export function ProviderList({
}: ProviderListProps) {
const { t } = useTranslation();
const [testState, setTestState] = useState<Record<string, TestState>>({});
const health = useHealthCheck(agent);
// managed(OAuth 登录托管)供应商排在最前;其余保持原有顺序(稳定排序)。
const sortedProviders = [...providers].sort(
@@ -312,28 +378,69 @@ export function ProviderList({
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h2 className="font-medium text-content-primary">{t("providers")}</h2>
<button
type="button"
onClick={onAdd}
title={t("addProvider")}
className="w-9 h-9 flex items-center justify-center rounded-full bg-orange-500 hover:bg-orange-600 text-white shadow-lg focus:ring-2 focus:ring-orange-400 focus:outline-none transition-colors"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
<div className="flex items-center justify-between gap-3 px-4 py-3 border-b border-border">
<div className="flex items-center gap-3 min-w-0">
<h2 className="font-medium text-content-primary shrink-0">
{t("providers")}
</h2>
{/* 体检汇总:检测中 / 请求失败 / x 正常 / y 失败 */}
{(health.running || health.ran || health.error) && (
<span
className={`text-xs tabular-nums truncate ${
health.error ? "text-red-500 dark:text-red-400" : "text-content-muted"
}`}
title={health.error ? localizeHealthError(health.error, t) : undefined}
>
{health.error
? t("healthRequestFailed", {
error: localizeHealthError(health.error, t),
})
: health.running
? t("healthCheckRunning")
: t("healthSummary", {
ok: health.okCount,
fail: health.failCount,
})}
</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={health.run}
disabled={health.running}
title={t("healthCheckHint")}
aria-label={t("healthCheck")}
className="flex items-center gap-1.5 h-9 px-3 rounded-full border border-border text-sm text-content-muted hover:text-content-primary hover:bg-hover-2 disabled:opacity-50 focus:ring-2 focus:ring-blue-500 focus:outline-none transition-colors whitespace-nowrap"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
{health.running ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Stethoscope className="w-4 h-4" />
)}
{t("healthCheck")}
</button>
<button
type="button"
onClick={onAdd}
title={t("addProvider")}
className="w-9 h-9 flex items-center justify-center rounded-full bg-orange-500 hover:bg-orange-600 text-white shadow-lg focus:ring-2 focus:ring-orange-400 focus:outline-none transition-colors"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div>
</div>
<div className="flex-1 overflow-auto p-4">
@@ -371,6 +478,9 @@ export function ProviderList({
defaultModel={defaultModel}
models={models}
ts={testState[provider.name]}
health={health.results[provider.name]}
healthRunning={health.running}
healthRan={health.ran}
onEdit={onEdit}
onDelete={onDelete}
onDuplicate={onDuplicate}
+120
View File
@@ -5,12 +5,20 @@ import { listen } from "@tauri-apps/api/event";
import { useTranslation, type Language } from "../i18n";
import { useTheme, type Theme } from "../hooks/useTheme";
import type { UpdateInfo } from "../hooks/useUpdateCheck";
import {
useKimiCodeVersion,
isNewerVersion,
type KimiCodeStatus,
} from "../hooks/useKimiCodeVersion";
function formatVersion(version: string): string {
const v = version.trim();
return v.startsWith("v") || v.startsWith("V") ? v : `v${v}`;
}
/** 上游 kimi-code releases 页面。 */
const KIMI_CODE_RELEASES_URL = "https://github.com/MoonshotAI/kimi-code/releases";
interface SettingsModalProps {
open: boolean;
onClose: () => void;
@@ -30,6 +38,11 @@ export function SettingsModal({
}: SettingsModalProps) {
const { t, lang, setLang } = useTranslation();
const { theme, setTheme } = useTheme();
const {
info: kimiCodeInfo,
checking: kimiCodeChecking,
checkNow: checkKimiCode,
} = useKimiCodeVersion();
const [appVersion, setAppVersion] = useState("");
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
const [downloadedPath, setDownloadedPath] = useState<string | null>(null);
@@ -158,6 +171,47 @@ export function SettingsModal({
return d.toLocaleString(lang === "zh" ? "zh-CN" : "en-US");
};
// 状态徽标:绿=已适配 / 黄=建议升级 KimiSwitch / 橙=部分兼容 / 灰=未知或未安装
const kimiCodeBadge = (status: KimiCodeStatus) => {
switch (status) {
case "current":
return {
label: t("kimiCodeStatusCurrent"),
className:
"bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border-green-300 dark:border-green-500/30",
};
case "outdated":
return {
label: t("kimiCodeStatusOutdated"),
className:
"bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 border-yellow-300 dark:border-yellow-500/30",
};
case "partial":
return {
label: t("kimiCodeStatusPartial"),
className:
"bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 border-orange-300 dark:border-orange-500/30",
};
case "unknown":
return {
label: t("kimiCodeStatusUnknown"),
className: "bg-hover-2 text-content-muted border-border",
};
default:
return {
label: t("kimiCodeStatusNotInstalled"),
className: "bg-hover-2 text-content-muted border-border",
};
}
};
const kimiCodeUpdateAvailable =
!!kimiCodeInfo?.installed &&
!!kimiCodeInfo?.latest &&
isNewerVersion(kimiCodeInfo.latest, kimiCodeInfo.installed);
const kimiCodeBadgeInfo = kimiCodeInfo ? kimiCodeBadge(kimiCodeInfo.status) : null;
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
@@ -362,6 +416,72 @@ export function SettingsModal({
</div>
</section>
{/* Kimi Code CLI version & compatibility */}
<section>
<h3 className="mb-3 text-[11px] uppercase tracking-wider text-content-muted">
{t("kimiCodeSection")}
</h3>
<div className="space-y-2 rounded-lg border border-border bg-input p-3">
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("kimiCodeInstalled")}</span>
<span className="font-medium text-content-primary tabular-nums">
{kimiCodeInfo?.installed ? formatVersion(kimiCodeInfo.installed) : "—"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("kimiCodeLatest")}</span>
<span className="font-medium text-content-primary tabular-nums">
{kimiCodeInfo?.latest ? formatVersion(kimiCodeInfo.latest) : "—"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("kimiCodeStatusLabel")}</span>
{kimiCodeBadgeInfo ? (
<span
className={`text-xs px-2 py-0.5 rounded-full border ${kimiCodeBadgeInfo.className}`}
>
{kimiCodeBadgeInfo.label}
</span>
) : (
<span className="text-xs text-content-muted">—</span>
)}
</div>
<div className="pt-2 flex items-center gap-2">
<button
type="button"
onClick={checkKimiCode}
disabled={kimiCodeChecking}
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 disabled:opacity-50 transition-colors flex items-center gap-1.5"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={kimiCodeChecking ? "animate-spin" : ""}
>
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<polyline points="21 3 21 9 15 9" />
</svg>
{kimiCodeChecking ? t("checking") : t("kimiCodeCheck")}
</button>
{kimiCodeUpdateAvailable && (
<button
type="button"
onClick={() => openExternal(KIMI_CODE_RELEASES_URL)}
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 transition-colors"
>
{t("kimiCodeOpenReleases")}
</button>
)}
</div>
</div>
</section>
{/* About */}
<section>
<h3 className="mb-3 text-[11px] uppercase tracking-wider text-content-muted">
+26 -1
View File
@@ -364,7 +364,7 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
)}
</div>
{/* timeout + auto query interval */}
{/* timeout + auto query interval + alert threshold */}
<div className="rounded-lg border border-border px-4 py-4 grid grid-cols-2 gap-6">
<div>
<label className="block text-sm text-content-muted mb-1.5">{t("usageTimeout")}</label>
@@ -394,6 +394,31 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
/>
<p className="mt-1 text-xs text-content-muted">{t("usageAutoIntervalHint")}</p>
</div>
<div>
<label className="block text-sm text-content-muted mb-1.5">{t("usageThreshold")} %</label>
<input
type="number"
min={0}
max={100}
className="w-32 bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={cfg.threshold ?? ""}
onChange={(e) => {
const raw = e.target.value.trim();
// 清空输入框写回 undefined(关闭),而不是 0:两者判定等价,
// 但 undefined 会被 serde 的 skip_serializing_if 从 SQLite
// JSON 里省掉,不留冗余字段。
if (raw === "") {
setCfg({ ...cfg, threshold: undefined });
return;
}
const n = parseInt(raw, 10);
// 非数字(如 "1e" / "-")不落库,保留上一次的有效值。
if (Number.isNaN(n)) return;
setCfg({ ...cfg, threshold: Math.min(100, Math.max(0, n)) });
}}
/>
<p className="mt-1 text-xs text-content-muted">{t("usageThresholdHint")}</p>
</div>
</div>
</>
)}
+38 -11
View File
@@ -1,7 +1,13 @@
import { useEffect, useState } from "react";
import { Loader2, RefreshCw } from "lucide-react";
import { useTranslation } from "../i18n";
import { formatAmount, localizeUsageError, planLabel } from "../lib/usage-display";
import {
BOOSTER_WALLET_PLAN,
formatAmount,
localizeUsageError,
planLabel,
} from "../lib/usage-display";
import { isAlertTier } from "../hooks/useUsageQuery";
import type { UsageData, UsageQueryState } from "../hooks/useUsageQuery";
interface UsageFooterProps {
@@ -14,6 +20,20 @@ interface UsageFooterProps {
variant?: "detail" | "compact";
}
/**
* 金额行 vs 配额行:NewAPI 与 Kimi 加力钱包(booster_wallet)只报金额,其
* used/total 是钱数而不是配额比例,必须走 formatAmount 渲染路径。判定只能看
* planName —— sub2api 的 USD 行 unit 同样不是 "%",但语义仍是「额度用了百分之
* 多少」,按 unit 判定会把它误渲染成余额。
*/
function isMoneyRow(d: UsageData): boolean {
return d.planName === "NewAPI" || d.planName === BOOSTER_WALLET_PLAN;
}
function isPlanRow(d: UsageData): boolean {
return !!d.planName && !isMoneyRow(d) && (d.total != null || d.used != null);
}
export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
const { t } = useTranslation();
const { status, data, error, updatedAt, supported, refresh } = usage;
@@ -85,10 +105,7 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
// A "plan" is a quota tier (used/total, e.g. 5h / weekly limit). Balance
// entries carry only remaining + unit — their plan_name is a currency /
// brand label and must render as an amount, not a percentage.
const isPlan =
!!d.planName &&
d.planName !== "NewAPI" &&
(d.total != null || d.used != null);
const isPlan = isPlanRow(d);
if (d.isValid === false) {
return { icon: "⚡", text: t("usageInvalidKey"), color: "text-red-500 dark:text-red-400" };
}
@@ -207,13 +224,14 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
{data.map((d, i) => {
// Same plan-vs-balance rule as compactSummary: balance rows have no
// quota fields (total/used), so they render as an amount.
const isPlan =
!!d.planName &&
d.planName !== "NewAPI" &&
(d.total != null || d.used != null);
const isPlan = isPlanRow(d);
const pct = isPlan ? percentOf(d) : null;
const color =
pct == null
// 预警高亮:usage.alert 已按「查询成功」门控,失败时保留的上次数据
// 不会被误标红(此处是唯一的高亮来源,随每次查询结果重算)。
const rowAlert = usage.alert && isAlertTier(d, usage.threshold);
const color = rowAlert
? "text-red-500 dark:text-red-400"
: pct == null
? "text-content-muted"
: pct < 70
? "text-green-600 dark:text-green-400"
@@ -248,6 +266,15 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
>
{main}
</span>
{rowAlert && (
<span
className="shrink-0 text-red-500 dark:text-red-400"
title={t("usageThresholdAlert")}
aria-label={t("usageThresholdAlert")}
>
⚠
</span>
)}
{resetText && (
<span className="text-content-muted shrink-0">{resetText}</span>
)}