AI

Cache Indicator

A prompt-cache chip for one request — hit / partial / miss / write derived from the token counts, a tooltip breakdown of cached vs fresh tokens and money saved, and a self-correcting TTL ring that flips the chip to expired and fires onExpire exactly once.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { DatabaseBackup, DatabaseX, DatabaseZap, Layers, TimerOff } from "lucide-react"

import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Outcome table
 *
 * One editable row per claim the chip is allowed to make about a prompt cache.
 * The ladder mirrors what a provider actually reports for a single request:
 * the prefix was read back (hit), partly read back (partial), not read at all

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/cache-indicator.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "CacheIndicator" component: a chip that
says what one request did with a provider's prompt cache, and how long the
cached prefix stays warm. Dependencies: lucide-react for the glyphs and a
shadcn/Radix Tooltip for the breakdown. No date library, no chart library.

Contract
- forwardRef<HTMLElement>, extends React.HTMLAttributes<HTMLElement> minus
  children. It renders a <button> when there is something to reveal and a
  <span> when there is not; the remaining props spread onto whichever it is.
- Usage numbers, all optional and coerced to non-negative integers:
    cachedTokens  — prompt tokens read back from the cache
    freshTokens   — prompt tokens the model had to process
    writtenTokens — prompt tokens this request wrote into the cache
- state?: "hit" | "partial" | "miss" | "write" | "expired". Omit it and the chip
  derives one: cached>0 && fresh>0 -> partial; cached>0 -> hit; written>0 ->
  write; otherwise miss.
- Window: expiresAt?: Date | number (absolute deadline) and ttlMs?: number (the
  window length, default 300000 = a five-minute provider window). ttlMs is the
  ring's denominator; passed WITHOUT expiresAt it also anchors a window at
  mount. warnAt?: number — remaining time below which the window is urgent,
  default min(60s, window/4).
- countdown?: "auto" | "ring" | "text" | "none", default "auto".
- onExpire?: () => void. pricing?: { fresh: number; cached?: number } — price
  per 1M tokens, enables the savings row. currency?: string, default "$" (a
  prefix, not an ISO code).
- Presentation: label?, description? (null drops it), showRatio? (default true),
  showLabel? (default true), size? "sm" | "md" | "lg", tone? "soft" | "outline",
  color? (a theme token, also opting out of the urgency tint), icon?
  (undefined = state glyph, null/false = none), details? "none" | "tooltip",
  fields?: { label, value }[], side/align/delayDuration for the tooltip,
  formatRemaining?(ms), formatTokens?(n).
- One editable table maps each state to { label, description, accent token,
  glyph }: hit -> var(--chart-2)/DatabaseZap, partial -> var(--chart-4)/Layers,
  miss -> var(--muted-foreground)/DatabaseX, write -> var(--chart-1)/
  DatabaseBackup, expired -> var(--muted-foreground)/TimerOff. Adding a state is
  one row, not a new branch.

Behavior — outcome vs window
- The token counts describe what THIS request did; the TTL describes whether the
  entry is still worth anything to the NEXT one. Once the window elapses that is
  the more useful headline, so "expired" SUPERSEDES the derived or explicit
  state: the chip's label, glyph and accent all switch, while the breakdown
  keeps reporting the original outcome and adds "Window: elapsed".
- Urgency (remaining <= warnAt) is a presentation state, not a semantic one: it
  retints the accent to the expiring token and forces the numeric value onto the
  chip. An explicit `color` pins the accent and opts out of the retint.
- Ratios round honestly. 99.6% cached must never print "100%" next to the word
  "partial" and 0.4% must never print "0%" next to the word "hit": clamp to
  99 while any fresh token exists, clamp to 1 while any cached token exists.
- Savings = cachedTokens / 1e6 * (pricing.fresh - (pricing.cached ?? 0)), shown
  only when positive and finite. Money uses 4 decimals below one cent so a
  sub-cent saving does not print as 0.00.

Behavior — the clock (the part that is easy to get wrong)
- Never read Date.now() during render. A deadline resolved in the render body
  gives the server one answer and the client another, which is a hydration
  mismatch on every badge on the page. Keep the remaining time in state that
  ONLY a timer callback writes, and arm the timer from an effect with a zero
  delay, so nothing writes state synchronously in the effect body either.
- Deadline = expiresAt when given, otherwise mount time + ttlMs (only when the
  caller passed a ttlMs). Re-arm the whole thing when expiresAt or ttlMs change;
  a caller re-creating an equal Date every render must NOT restart it, so
  normalise the date to a number before it reaches the dependency array.
- Self-correcting schedule instead of setInterval(1000): after each tick, sleep
  exactly ((remaining - 1) % 1000) + 1 ms (floored at ~50ms), recomputed from
  the wall clock. A fixed interval drifts, and a throttled background tab makes
  it skip whole seconds on return.
- Listen for visibilitychange and resync immediately when the tab becomes
  visible again: a hidden tab throttles timers to roughly once a minute.
- One-shot expiry lock, scoped to the current deadline: two flags, "was the
  window still open at some point while mounted" and "has the callback run".
  onExpire fires only when both say yes, so mounting on an already-dead cache is
  history, not an event — and it can never fire twice for the same window.
- Once remaining hits zero, stop scheduling entirely. Clear the timeout and
  remove the listener in the effect cleanup, always.
- The chip's element type (button vs span) is decided from the PROPS, never from
  the clock: a chip that promoted itself from span to button when the first tick
  landed would swap DOM nodes under the pointer and drop focus.

Behavior — disclosure and accessibility
- Affordance follows content: with usage numbers, a window or extra fields there
  is a tooltip, a pointer cursor and a focus stop; with nothing behind it the
  chip is an inert <span>. details="none" | "tooltip" overrides that.
- The interactive chip carries the whole story as its aria-label ("Cache hit,
  24,576 of 24,576 prompt tokens from cache, expires in about 4 minutes") —
  a tooltip is not reachable by a screen reader user. The inert chip carries the
  same sentence in an sr-only span.
- The accessible name states the remaining time at MINUTE granularity ("about 4
  minutes", "under a minute"). A per-second aria-label makes a screen reader
  re-announce the focused chip once a second.
- The ring is aria-hidden decoration: the exact remaining time is always
  available as text, in the breakdown and on the chip itself once urgent.
- Default formatters are deterministic (manual thousands grouping, "4m 32s",
  fixed decimals). Intl.NumberFormat with an implicit locale groups one way on
  the server and another in a de-DE browser, and this chip renders during
  hydration; a consumer who wants locale rules passes formatTokens /
  formatRemaining.

Rendering & styling
- Semantic tokens only: var(--chart-1..5) and var(--muted-foreground) for the
  state accents, bg/border tints via color-mix(in oklab, <token> 14% / 45%,
  transparent), ring / ring-ring for focus and hover, currentColor for the ring
  arc and the split bar. No hex, no rgb(), no oklch() literals.
- The capsule: inline-flex, w-fit, shrink-0, rounded-full, align-middle,
  whitespace-nowrap, three size rows (h-5/h-6/h-7) each pairing a box class, a
  square class for the icon-only shape, a glyph size and a type size. No
  select-none — a cache annotation should travel with the text a reader copies.
- TTL ring: one 24x24 svg, two circles, -rotate-90, strokeDasharray = 2*PI*r and
  strokeDashoffset = length * (1 - remaining/window), strokeLinecap round. The
  1s linear transition on stroke-dashoffset matches the tick period exactly, so
  the arc sweeps instead of stepping.
- prefers-reduced-motion: the ring is dropped and the numeric value takes its
  place — the information survives, only the animation goes. Keep
  motion-reduce:transition-none on the arc for the explicit countdown="ring"
  case, and read the media query with useSyncExternalStore (server snapshot
  false) so hydration stays clean.
- Breakdown panel (inside the tooltip, an inverted surface): title row with the
  glyph, the state sentence at opacity-70, a two-segment cached/fresh bar built
  from currentColor at two opacities, then a <dl> of rows — From cache (tokens +
  %), Processed fresh, Written to cache, Saved, Expires / Window, then any
  custom fields. Accents are dropped in here: on an inverted surface only
  currentColor is contrast-safe.
- Merge every className through cn() and put the consumer's `style` after the
  computed surface so an override wins.

Customization levers
- The state table is the main lever: change a label, sentence, glyph or accent
  token per row, or add a state (e.g. "shared" for a cross-request cache) by
  adding one row and one derivation clause.
- Window semantics: ttlMs for the ring denominator, warnAt for how early the
  chip starts shouting. Providers with a one-hour window want ttlMs 3600000 and
  warnAt around 300000.
- Countdown presentation: "ring" for the ambient dial, "text" for dense log
  rows and dashboards, "none" when a neighbouring column already owns the time —
  onExpire still fires in every mode.
- Density: size sm for message footers and table cells, md inline, lg for a
  detail header; tone="outline" where a tinted pill would be too loud;
  showLabel={false} for a glyph-only chip in a tight row; showRatio={false} to
  drop the percentage.
- Breakdown content: description={null} removes the explanatory sentence, fields
  adds rows (cache key, break point, provider, region), pricing/currency turn
  the savings row on and off.
- Disclosure: details="none" turns even a data-carrying chip into inert text
  (useful when the row already has an expander); raise delayDuration for a
  calmer hover, or move side/align when the chip sits at the edge of a panel.
- Formatters: pass formatTokens for compact "24.6k" counts and formatRemaining
  for "mm:ss" or a locale-aware relative time.

Concepts

  • Outcome vs window — the token counts answer "what did this request do", the TTL answers "is the entry still worth anything to the next one"; while the window is alive the outcome is the headline, and the moment it elapses expired takes over the label, glyph and accent while the breakdown keeps the original numbers.
  • Derived state — nothing has to be classified by hand: cached and fresh both non-zero reads as partial, cached alone as hit, written alone as write, neither as miss — so a raw usage payload can be piped straight in, and an explicit state is only needed when the server already made the call.
  • Self-correcting tick — instead of a 1000 ms interval that drifts and skips, each tick sleeps exactly until the displayed second changes, recomputed from the wall clock, with a visibility resync so a tab that was throttled in the background never comes back showing a stale number.
  • One-shot expiry lock — the callback is guarded by two flags scoped to the current deadline, "the window was open while I was mounted" and "I have already fired", so a badge mounted on a long-dead cache reports history silently and no window can fire twice.
  • Hydration-safe clock — the deadline is resolved inside an effect and the remaining time is written only by the timer, because reading Date.now() during render gives the server one answer and the browser another; until the first tick lands the chip simply has no countdown.
  • Numeric fallback — under prefers-reduced-motion the sweeping arc is replaced by the number it was encoding rather than merely frozen: the decoration disappears, the information does not.

On This Page