a7005e5d2f
* chore: revert agent subscription usage in settings and chat tooltip
Reverts #1697 (55c8b461b). The subscription usage plugin now covers this,
so the built-in Settings > Agents section and the context-doughnut tooltip
rows are no longer needed.
Conflicts resolved to preserve later work on the same files:
- token-usage-display.tsx/.test.tsx: kept the pinnable tooltip (#1869),
the rebuilt context-window layout (#1741), and the ACP/API source row
(#1752); removed only SessionUsageRows and its imports.
- settings/agents/page.tsx: #1741 moved <AgentUsageSection /> above the
pre-existing <Separator />, so the revert missed the JSX; removed it
and kept the separator.
- agent-usage-section.tsx/.test.tsx: deleted (later changes were cosmetic
tweaks to the section itself).
Office-side subscription surfaces (dashboard quota card, agent detail
QuotaCard, internal/agent/usage clients) predate #1697 and are untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(usage): keep #1697 client fixes when reverting its UI surface
#1697 bundled two things: the Settings > Agents section plus doughnut
tooltip rows, and a set of genuine bug fixes to the underlying provider
usage clients. Reverting it wholesale threw away the second group, which
is still live code behind the office dashboard quota card and the agent
detail QuotaCard.
Restores the client layer to its post-#1697 state (billing.go, cache.go,
client_claude.go, client_codex.go, fileutil.go, types.go and the two
client tests), fixing the defects CodeRabbit flagged on #1940:
- billing type is recomputed per call again instead of being frozen for
the process lifetime by sync.OnceValue, so login/logout is observed
without a backend restart
- usage cache regains fetch coalescing and short negative caching, so a
provider outage no longer means one outbound call per request
- Claude usage parses float utilization and the richer limits[] array
instead of failing to decode
- Claude token refresh patches only claudeAiOauth in a decoded map and
writes atomically via temp+rename, preserving unknown keys in the
credentials file the real CLI shares
- Codex reads tokens.access_token rather than a nonexistent flat token
field, and its doc comment matches the ~/.codex/auth.json path
The removed UI/API surface stays reverted: GET /api/v1/agents/usage, the
host usage lister, the settings section, and the tooltip rows.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
228 lines
7.9 KiB
TypeScript
228 lines
7.9 KiB
TypeScript
"use client";
|
|
|
|
import { memo, useEffect, useId, useRef, useState } from "react";
|
|
import { IconInfoCircle } from "@tabler/icons-react";
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
|
|
import { cn } from "@/lib/utils";
|
|
import { useSessionContextWindow } from "@/hooks/domains/session/use-session-context-window";
|
|
|
|
type TokenUsageDisplayProps = {
|
|
sessionId: string | null;
|
|
className?: string;
|
|
};
|
|
|
|
/**
|
|
* A context-window report is only trustworthy when we have a positive window
|
|
* size and usage that does not exceed it. `used > size` is impossible for a
|
|
* real window, so it means the agent (via the ACP bridge) reported a stale or
|
|
* wrong `size` (for example, usage and window metadata from different turns).
|
|
* In that case we hide the indicator instead of showing a confusing >100%.
|
|
* `used === size` (exactly full) is valid and still renders.
|
|
*/
|
|
export function isContextWindowReliable(size: number, used: number): boolean {
|
|
return size > 0 && used <= size;
|
|
}
|
|
|
|
function formatNumber(num: number): string {
|
|
if (num >= 1_000_000) {
|
|
return `${(num / 1_000_000).toFixed(1)}M`;
|
|
}
|
|
if (num >= 1_000) {
|
|
return `${(num / 1_000).toFixed(1)}K`;
|
|
}
|
|
return num.toLocaleString();
|
|
}
|
|
|
|
function getCircleColor(efficiency: number): string {
|
|
if (efficiency >= 90) return "text-yellow-500";
|
|
if (efficiency >= 75) return "text-yellow-300";
|
|
if (efficiency >= 50) return "text-blue-500";
|
|
return "text-blue-300";
|
|
}
|
|
|
|
function usePinnableTooltip() {
|
|
const [open, setOpen] = useState(false);
|
|
const pinnedRef = useRef(false);
|
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
|
|
useEffect(() => {
|
|
const closePinnedTooltip = () => {
|
|
pinnedRef.current = false;
|
|
setOpen(false);
|
|
};
|
|
const closeOnOutsidePointer = (event: PointerEvent) => {
|
|
if (!pinnedRef.current || !(event.target instanceof Node)) return;
|
|
if (
|
|
triggerRef.current?.contains(event.target) ||
|
|
(event.target instanceof Element && event.target.closest('[data-slot="tooltip-content"]'))
|
|
) {
|
|
return;
|
|
}
|
|
closePinnedTooltip();
|
|
};
|
|
const closeOnEscape = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape" && pinnedRef.current) closePinnedTooltip();
|
|
};
|
|
|
|
document.addEventListener("pointerdown", closeOnOutsidePointer);
|
|
document.addEventListener("keydown", closeOnEscape);
|
|
return () => {
|
|
document.removeEventListener("pointerdown", closeOnOutsidePointer);
|
|
document.removeEventListener("keydown", closeOnEscape);
|
|
};
|
|
}, []);
|
|
|
|
return {
|
|
open,
|
|
triggerRef,
|
|
onOpenChange: (nextOpen: boolean) => {
|
|
if (!nextOpen && pinnedRef.current) return;
|
|
setOpen(nextOpen);
|
|
},
|
|
onTriggerClick: () => {
|
|
pinnedRef.current = !pinnedRef.current;
|
|
setOpen(pinnedRef.current);
|
|
},
|
|
};
|
|
}
|
|
|
|
function ContextWindowRing({ usagePercent }: { usagePercent: number }) {
|
|
const radius = 10;
|
|
const strokeWidth = 2.5;
|
|
const circumference = 2 * Math.PI * radius;
|
|
const strokeDashoffset = circumference * (1 - usagePercent / 100);
|
|
|
|
return (
|
|
<svg viewBox="0 0 24 24" className="size-5 -rotate-90" aria-hidden="true">
|
|
<circle
|
|
cx="12"
|
|
cy="12"
|
|
r={radius}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={strokeWidth}
|
|
className="text-muted"
|
|
/>
|
|
<circle
|
|
cx="12"
|
|
cy="12"
|
|
r={radius}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={strokeWidth}
|
|
strokeLinecap="round"
|
|
strokeDasharray={circumference}
|
|
strokeDashoffset={strokeDashoffset}
|
|
className={cn(getCircleColor(usagePercent), "transition-all duration-300 ease-out")}
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ContextWindowSource({ source }: { source: "acp" | "api" | undefined }) {
|
|
const helpId = useId();
|
|
|
|
if (!source) return null;
|
|
const description =
|
|
source === "acp"
|
|
? "ACP is the active session's effective window, reported by the agent."
|
|
: "API is the model's advertised maximum from the catalogue and is used when ACP omits the window.";
|
|
|
|
return (
|
|
<div className="group relative flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground">
|
|
<span>Source</span>
|
|
<span className="font-medium text-foreground">{source.toUpperCase()}</span>
|
|
<button
|
|
type="button"
|
|
aria-label="About context window source"
|
|
aria-describedby={helpId}
|
|
className="inline-flex size-6 cursor-help items-center justify-center text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring sm:size-4"
|
|
>
|
|
<IconInfoCircle className="h-3 w-3" />
|
|
</button>
|
|
<span
|
|
id={helpId}
|
|
role="tooltip"
|
|
className="pointer-events-none absolute right-0 bottom-[calc(100%+0.375rem)] z-10 w-60 rounded-md border border-border bg-popover px-3 py-1.5 text-xs text-popover-foreground opacity-0 shadow-sm transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
|
|
>
|
|
{description}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const TokenUsageDisplay = memo(function TokenUsageDisplay({
|
|
sessionId,
|
|
className,
|
|
}: TokenUsageDisplayProps) {
|
|
const tooltip = usePinnableTooltip();
|
|
const contextWindow = useSessionContextWindow(sessionId);
|
|
|
|
if (!contextWindow) return null;
|
|
|
|
const { size, used, source } = contextWindow;
|
|
|
|
// Hide when there's no data yet (size 0) or the report is impossible
|
|
// (used > size) — see isContextWindowReliable.
|
|
if (!isContextWindowReliable(size, used)) return null;
|
|
|
|
const usagePercent = (used / size) * 100;
|
|
|
|
return (
|
|
// The UI wrapper defaults this to true; the source control must remain reachable inside.
|
|
<TooltipProvider disableHoverableContent={false}>
|
|
<Tooltip open={tooltip.open} onOpenChange={tooltip.onOpenChange}>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
ref={tooltip.triggerRef}
|
|
type="button"
|
|
aria-label={`Context window: ${usagePercent.toFixed(0)}% used`}
|
|
aria-expanded={tooltip.open}
|
|
onClick={tooltip.onTriggerClick}
|
|
className={cn(
|
|
"flex size-7 cursor-help items-center justify-center rounded-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring sm:size-5",
|
|
className,
|
|
)}
|
|
>
|
|
<ContextWindowRing usagePercent={usagePercent} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" className="pointer-events-auto">
|
|
<div className="min-w-64 text-xs">
|
|
<div className="space-y-2" data-testid="context-window-usage">
|
|
<div className="flex items-baseline justify-between gap-6">
|
|
<span className="text-[10px] font-medium uppercase text-muted-foreground">
|
|
Context window
|
|
</span>
|
|
<span className="text-base font-semibold tabular-nums text-foreground">
|
|
{usagePercent.toFixed(0)}%
|
|
</span>
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
"h-1.5 overflow-hidden rounded-full bg-muted",
|
|
getCircleColor(usagePercent),
|
|
)}
|
|
>
|
|
<div
|
|
className="h-full rounded-full bg-current transition-all duration-300 ease-out"
|
|
style={{ width: `${usagePercent}%` }}
|
|
/>
|
|
</div>
|
|
<div
|
|
className="flex min-h-6 items-center justify-between gap-3"
|
|
data-testid="context-window-token-row"
|
|
>
|
|
<span className="text-[11px] tabular-nums text-muted-foreground">
|
|
{formatNumber(used)} of {formatNumber(size)} tokens
|
|
</span>
|
|
<ContextWindowSource source={source} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
);
|
|
});
|