│
+└────────────────────────────────────────┘
+```
+
+```tsx
+…}>
+
+ …
+
+
+```
+
+**Rules:**
+- `KpiRow` only when ≤4 hero numbers; more → group inside the grid
+- One primary viz per page, span ≥8 cols
+- `variant="terminal"` for live-log / `health --watch` aesthetics
+
+---
+
+## 3. Composing a new page
+
+**Config page (default for any provider / profile / account / api management page):**
+
+```tsx
+
+ } // rail owns identity (§1b) — brand, CTA, list, status
+ form={} // tabs (if any) live INSIDE this pane's scroll area
+ json={} // optional; same top edge as form (§0c)
+ />
+
+```
+
+No `PageHeader`. No description band. The rail header carries the brand and section name; the rail footer carries status / counts.
+
+**Dashboard / home page (no rail):**
+
+```tsx
+
+ // §1a — one row, ≤4 inline stats
+ // optional grid below
+
+```
+
+**Monitor page without a rail:**
+
+```tsx
+
+ {/* §1c — only valid here */}
+ }>
+ …
+
+
+```
+
+Target LOC for a new page: **~80** for typical config, **~120** for monitor with hero strip. Target LOC for an outlier rewrite: **<400**.
+
+---
+
+## 4. Anti-patterns (REJECT IN REVIEW)
+
+### 4a. `PageHeader` stacked above `ConfigLayout`
+
+❌ DO NOT:
+
+```tsx
+
+
+
+
+```
+
+The PageHeader steals ~80px from the body, duplicates identity the rail already carries, and pushes the form + json panes below the fold. Use rail-anchored identity (§1b) — move the title into the rail header and remove the description band entirely. See §0b.
+
+### 4b. Tab bar offsetting sibling panes
+
+❌ DO NOT place a tab bar (e.g. `Environment / Info & Usage`) at the top of the form pane such that the json pane on the right starts ~40–60px lower. Form and json are **siblings** sharing one top edge (§0c). Tabs belong **inside** the form pane's scroll area, not above it as a separate row.
+
+### 4c. Description bands that repeat the rail
+
+❌ DO NOT add a description below the title that simply restates what the left rail's items already convey ("Premium APIs, local runtimes, custom endpoints" when the rail already lists those entities). If the rail shows it, the band is noise.
+
+### 4d. Blank vertical band above either column
+
+❌ DO NOT introduce padding, spacing, or a wrapper that produces a >24px blank band between the global topbar and the top of either column. The two columns of a `ConfigLayout` are flush against the global topbar (§0a). If a wrapper requires that band, the wrapper is wrong.
+
+---
+
+## 5. Color & accent usage
+
+The CCS palette is **Pampas (warm cream) + Crail (terracotta orange)**, defined in `src/index.css` as CSS variables. **Never introduce new hues.** Every visual decision routes through these tokens:
+
+| Token | Role | When to use |
+|-------|------|-------------|
+| `--background` (Pampas) | Page canvas | Outermost shell only |
+| `--card` | Elevated surface | FormPane shell, JSON header bar, cards |
+| `--card/60`, `--card/80` | Soft elevation | FormSection bg, gradient header tails |
+| `--muted/20`, `--muted/30`, `--muted/40` | Pane wash, footer anchor, JSON shell | Differentiating sibling panes without a hard border |
+| `--accent` (Crail) | Identity / focus | Section dot, top-edge 1px strip, primary CTA, status pill on key chrome |
+| `--accent/30`, `--accent/40` | Whisper accent | Vertical stripe on FormSection edge, top-edge strip on header bars |
+| `--accent/10` | Tint background | Status pill bg ("editable", "sensitive", "connected") |
+| `--muted-foreground` | Secondary text | Field labels, descriptions, hint copy |
+| `--destructive` | Error / danger | Form errors, "delete account" buttons, anti-pattern callouts |
+| `--ring` | Focus outlines | NEVER override per-component — let `focus-visible:ring-*` use the token |
+
+### 5a. The 1-accent-dot rule
+
+Each FormSection gets **exactly one accent dot** (1.5px circle, `bg-accent`) prefixing its title, and **one 2px Crail stripe** on the leading edge. These are the only places `--accent` saturates inside the body.
+
+The header bars (FormPane, JsonPane) carry a 1px `accent/40` strip at the top edge — a quiet tie-back to the Save button at the bottom.
+
+If a section needs more attention (e.g. a connected status), use an **outline pill** with `bg-accent/10` + `text-accent` + `border-accent/30`, never a filled `bg-accent` block in the body.
+
+### 5b. Differentiating sibling panes
+
+Form pane (`bg-card`) and JSON pane (`bg-muted/30`) MUST be visually distinct without a hard border line:
+
+- **Form pane** = elevated card surface, active editing zone
+- **JSON pane** = recessed muted surface, read-only inspection zone
+
+Inside the JSON pane, the `` block uses `bg-card/80` with `shadow-inner` so the code sits in a subtle embossed well — the user can tell at a glance which side is "yours to edit" and which is "computed for you to read".
+
+### 5c. Status pills
+
+Use small uppercase pills (10px font, `tracking-wider`, `rounded`, 1px border) for status. Two tones:
+
+- **Active / accent**: `border-accent/40 bg-accent/10 text-accent` — for `editable`, `connected`, `sensitive`, `default`
+- **Neutral / muted**: `border-border bg-muted/60 text-muted-foreground` — for `read only`, `unset`, `disabled`
+
+Do not use `bg-emerald-*` / `bg-amber-*` / `bg-blue-*` for status unless the semantic IS the color (e.g. health severity tiers in §1d Bespoke). Default to the palette tokens.
+
+### 5d. Errors and danger
+
+`--destructive` is the **only** red in the system. Use it for:
+- Inline form validation errors (`text-destructive` for messages, `border-destructive/40` on bad inputs)
+- Anti-pattern callouts in docs (the `§0 invariants` callout in `/_styleguide` uses it)
+- Truly destructive button actions ("Delete account", "Reset config")
+
+Never tint a description band, an info banner, or a hover state with `--destructive`.
+
+### 5e. Quick checklist for a new pane
+
+- [ ] FormSections use `bg-card/60` with the leading accent stripe + accent dot before each title
+- [ ] FormPane header has a 1px `accent/40` top strip and a `from-card to-card/70` gradient
+- [ ] FormPane footer uses `bg-muted/40` to anchor the primary save action
+- [ ] JsonPane shell uses `bg-muted/30`; header uses `bg-card/80` with the accent dot + status pill
+- [ ] No raw `text-blue-500`, `bg-green-100`, etc. — only palette tokens
+- [ ] Hover states change opacity of existing tokens, not hue (e.g. `before:bg-accent/30 hover:before:bg-accent/70`)
+
+### 5f. Interactive states (the three-tier intensity ladder)
+
+Every interactive element uses the same intensity ladder for accent presence:
+
+| State | Accent intensity | Example |
+|-------|------------------|---------|
+| Default | `accent/30` | FormSection leading stripe at rest |
+| Hover / scan | `accent/70` | FormSection stripe on mouse-over (peripheral cue) |
+| Focus / active | `accent` solid + soft ring | FormSection on `focus-within`, ListPane selected row |
+
+`FormSection` implements all three: stripe goes `30 → 70 → 100`, plus a soft `ring-accent/20` and shadow lift on focus-within. The user always knows which group "owns" their cursor.
+
+`ListPane` selected row uses the same ladder: 3px Crail stripe on the leading edge + `bg-accent/10` row tint + accent-tinted icon and badge. Hover gives a `accent/30` stripe preview without the row tint. This visually links the selected entity to the FormSection treatment on the right.
+
+Sticky headers (`FormPane.header`) include a soft inset bottom shadow so when the body scrolls under, depth is preserved without a hard divider line.
+
+### 5g. Sensitive fields
+
+Fields whose label matches the heuristic in `src/lib/sensitive-label.ts` (`isSensitiveLabel(label)`) are **automatically** rendered as sensitive. The pattern covers the common conventions: `AUTH_TOKEN | ACCESS_TOKEN | REFRESH_TOKEN | BEARER_TOKEN | API_KEY | API_TOKEN | API_SECRET | CLIENT_ID | CLIENT_SECRET | AWS_ACCESS_KEY_ID | AWS_SECRET_ACCESS_KEY | GCP/AZURE/GITHUB/GITLAB/OPENAI/ANTHROPIC/GEMINI variants of the above | PRIVATE_KEY | SSH_KEY | JWT | OAUTH | CREDENTIAL | PAT | PASSWORD | PASSPHRASE | WEBHOOK_SECRET | HMAC_KEY | SIGNING_KEY`. The match is case-insensitive and tolerates `_` / `-` separators. If the heuristic doesn't match a label that IS a credential, pass `sensitive` explicitly. Adding a new pattern means editing one regex in `sensitive-label.ts` — every consumer (Field, MaskedInput callers that delegate, future helpers) picks it up automatically.
+
+The treatment:
+
+- Lock glyph (`lucide-react/Lock`) prefixing the label, tinted `accent/70`
+- "sensitive" status pill on the right of the label row (accent tones per §5c)
+- `` by default with a reveal/hide eye-toggle on the right edge
+- Focus ring uses `ring-accent/40` + `border-accent/50` (vs. neutral `ring-ring` for ordinary fields) — the only place a default focus ring is overridden
+
+Pages MUST NOT roll their own sensitive-field UI. If the heuristic doesn't match a label, pass `sensitive` explicitly.
+
+### 5h. Raw configuration content
+
+JSON content inside `JsonPane` renders **plain** — no inline syntax highlighting from this design system. A dedicated JSON viewer with its own color coding will replace the `` in a follow-up; until then the **chrome** of the pane (shell, header strip, accent dot, status pill, embossed code well) carries the §5 treatment, and the **content** stays uncolored. Pages MUST NOT add inline JSON tints in the meantime.
+
+---
+
+## 6. When NOT to use either archetype
+
+These remain bespoke and are out of scope:
+- `/login` — minimal centered shell
+- Setup wizard — modal overlay
+- Dialogs — Radix `Dialog`
+
+---
+
+## 7. Decisions
+
+See [`design-decisions.md`](./design-decisions.md) for the resolved open questions and the v1.1 / v1.2 / v1.3 / v1.4 / v1.5 / v1.6 / v1.7 / v1.8 revision rationale.
diff --git a/ui/docs/health-redesign/expanded_view.png b/ui/docs/health-redesign/expanded_view.png
new file mode 100644
index 00000000..da449a53
Binary files /dev/null and b/ui/docs/health-redesign/expanded_view.png differ
diff --git a/ui/docs/health-redesign/fixed_expansion.png b/ui/docs/health-redesign/fixed_expansion.png
new file mode 100644
index 00000000..12963bef
Binary files /dev/null and b/ui/docs/health-redesign/fixed_expansion.png differ
diff --git a/ui/docs/health-redesign/initial_state.png b/ui/docs/health-redesign/initial_state.png
new file mode 100644
index 00000000..c63038af
Binary files /dev/null and b/ui/docs/health-redesign/initial_state.png differ
diff --git a/ui/docs/health-redesign/loading_state.png b/ui/docs/health-redesign/loading_state.png
new file mode 100644
index 00000000..2223d19b
Binary files /dev/null and b/ui/docs/health-redesign/loading_state.png differ
diff --git a/ui/docs/health-redesign/report.html b/ui/docs/health-redesign/report.html
new file mode 100644
index 00000000..80cad7dc
--- /dev/null
+++ b/ui/docs/health-redesign/report.html
@@ -0,0 +1,129 @@
+
+
+
+
+
+ UI Verification Report - Health Page Redesign
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Implementation Overview
+
+
+
+
Aesthetic Improvements
+
+ - ✓ Solid opaque header with shadow separation
+ - ✓ Contained mesh gradient (no top-bar bleed)
+ - ✓ Premium grainy texture via local SVG asset
+ - ✓ Refined typography and spacing (OKLCH colors)
+
+
+
+
Functional Verification
+
+ - ✓ Priority cards collapsed by default (Fix for 'blank space' bug)
+ - ✓ Entire card header now clickable for expansion
+ - ✓ Refresh button loading state confirmed
+ - ✓ "Apply Fix" spinner feedback
+
+
+
+
+
+
+
+ Redteam Review & Edge Cases
+
+
+
Edge Case: Asset Availability
+
Problem: External SVG noise textures were failing (404).
+
Resolution: Switched to a robust Base64 Data URI for the noise texture in health.tsx. This ensures the premium texture renders even in offline or high-security environments.
+
+
+
Edge Case: Responsive Overlap
+
Problem: Mesh gradient blur spilling into the header and sidebar.
+
Resolution: Implemented overflow-hidden on the content wrapper and adjusted mesh circles to top-[5%] instead of negative offsets. Added z-20 and solid background to the header for absolute containment.
+
+
+
+
+
+
+ Visual Proof
+
+
+
+
1. Initial State & Header Separation
+
+

+
+
Note the clean separation between the white header and the mesh background.
+
+
+
+
2. Interactive Loading Feedback
+
+

+
+
Spinner state triggered during 'Refresh' action.
+
+
+
+
3. Expanded Issue Drill-down
+
+

+
+
Detailed issue view with clear hierarchy and typography.
+
+
+
+
4. Expansion Fix (Header Clickable)
+
+

+
+
Corrected expansion logic: Cards start collapsed, and expanding them shows the fix without 'blank space'.
+
+
+
+
+
+
+
+
+
diff --git a/ui/docs/screenshots-phase2/after-03-health.png b/ui/docs/screenshots-phase2/after-03-health.png
new file mode 100644
index 00000000..f5f0728e
Binary files /dev/null and b/ui/docs/screenshots-phase2/after-03-health.png differ
diff --git a/ui/docs/screenshots-phase2/before-03-health.png b/ui/docs/screenshots-phase2/before-03-health.png
new file mode 100644
index 00000000..4e36b4f7
Binary files /dev/null and b/ui/docs/screenshots-phase2/before-03-health.png differ
diff --git a/ui/docs/screenshots/01-styleguide-overview.png b/ui/docs/screenshots/01-styleguide-overview.png
new file mode 100644
index 00000000..d9613b7d
Binary files /dev/null and b/ui/docs/screenshots/01-styleguide-overview.png differ
diff --git a/ui/docs/screenshots/02-config-multi-entity.png b/ui/docs/screenshots/02-config-multi-entity.png
new file mode 100644
index 00000000..789e9f20
Binary files /dev/null and b/ui/docs/screenshots/02-config-multi-entity.png differ
diff --git a/ui/docs/screenshots/03-config-single-entity.png b/ui/docs/screenshots/03-config-single-entity.png
new file mode 100644
index 00000000..848ffab9
Binary files /dev/null and b/ui/docs/screenshots/03-config-single-entity.png differ
diff --git a/ui/docs/screenshots/04-monitor-archetype.png b/ui/docs/screenshots/04-monitor-archetype.png
new file mode 100644
index 00000000..98d8734a
Binary files /dev/null and b/ui/docs/screenshots/04-monitor-archetype.png differ
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index 1ebba72f..caceebea 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -47,6 +47,11 @@ const HealthPage = lazy(() => import('@/pages/health').then((m) => ({ default: m
const SharedPage = lazy(() => import('@/pages/shared').then((m) => ({ default: m.SharedPage })));
const UpdatesPage = lazy(() => import('@/pages/updates').then((m) => ({ default: m.UpdatesPage })));
+// Dev-only: design system styleguide
+const StyleguidePage = import.meta.env.DEV
+ ? lazy(() => import('@/pages/_styleguide').then((m) => ({ default: m.StyleguidePage })))
+ : null;
+
// Loading fallback for lazy components
function PageLoader() {
return (
@@ -203,6 +208,16 @@ export default function App() {
}
/>
+ {StyleguidePage && (
+ }>
+
+
+ }
+ />
+ )}
diff --git a/ui/src/components/analytics/model-details-content.tsx b/ui/src/components/analytics/model-details-content.tsx
index b360b3da..ede1773b 100644
--- a/ui/src/components/analytics/model-details-content.tsx
+++ b/ui/src/components/analytics/model-details-content.tsx
@@ -13,6 +13,8 @@ export function ModelDetailsContent({ model }: ModelDetailsContentProps) {
const { privacyMode } = usePrivacy();
const { t } = useTranslation();
const ioRatioStatus = getIoRatioStatus(model.ioRatio);
+ const cacheTokens = model.cacheCreationTokens + model.cacheReadTokens;
+ const totalTokensLabel = cacheTokens > 0 ? 'All Tokens' : t('analyticsCards.totalTokens');
return (
@@ -49,7 +51,7 @@ export function ModelDetailsContent({ model }: ModelDetailsContentProps) {
{formatCompactNumber(model.tokens)}
- {t('analyticsCards.totalTokens')}
+ {totalTokensLabel}
diff --git a/ui/src/components/analytics/session-stats-card.tsx b/ui/src/components/analytics/session-stats-card.tsx
index dbe6ec21..d1b1d0b1 100644
--- a/ui/src/components/analytics/session-stats-card.tsx
+++ b/ui/src/components/analytics/session-stats-card.tsx
@@ -31,26 +31,18 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
const sessions = data.sessions;
const totalSessions = data.total;
-
- // Calculate average tokens per session
- const totalTokens = sessions.reduce((sum, s) => sum + (s.inputTokens + s.outputTokens), 0);
- const avgTokens = Math.round(totalTokens / sessions.length);
+ const sampledSessions = sessions.length;
+ const hasPartialSample = data.hasMore || data.offset > 0;
// Calculate total cost for visible sessions
const totalCost = sessions.reduce((sum, s) => sum + s.cost, 0);
const avgCost = totalCost / sessions.length;
- // Most recent session
- const lastSession = sessions[0];
- const lastActive = lastSession
- ? formatDistanceToNow(new Date(lastSession.lastActivity), { addSuffix: true })
- : 'N/A';
-
return {
+ displayedSessions: hasPartialSample ? sampledSessions : totalSessions,
totalSessions,
- avgTokens,
avgCost,
- lastActive,
+ hasPartialSample,
recentSessions: sessions.slice(0, 3),
};
}, [data]);
@@ -105,12 +97,16 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
- {stats.totalSessions}
+ {stats.displayedSessions}
- {/* TODO i18n: missing key for "Total Sessions" */}
- Total Sessions
+ {stats.hasPartialSample ? 'Sampled Sessions' : 'Total Sessions'}
+ {stats.hasPartialSample && (
+
+ {stats.totalSessions} total
+
+ )}
{/* Avg Cost */}
@@ -122,8 +118,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
- {/* TODO i18n: missing key for "Avg Cost/Session" */}
- Avg Cost/Session
+ {stats.hasPartialSample ? 'Recent Avg Cost' : 'Avg Cost/Session'}
@@ -159,7 +154,8 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
${session.cost.toFixed(2)}
- {formatCompact(session.inputTokens + session.outputTokens)} toks
+ {formatCompact(session.tokens ?? session.inputTokens + session.outputTokens)}{' '}
+ toks
diff --git a/ui/src/components/analytics/usage-summary-cards.tsx b/ui/src/components/analytics/usage-summary-cards.tsx
index 0ca03eee..98be74e0 100644
--- a/ui/src/components/analytics/usage-summary-cards.tsx
+++ b/ui/src/components/analytics/usage-summary-cards.tsx
@@ -22,6 +22,11 @@ interface UsageSummaryCardsProps {
export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
const { privacyMode } = usePrivacy();
const { t } = useTranslation();
+ const totalTokens = data?.totalTokens ?? 0;
+ const totalInputTokens = data?.totalInputTokens ?? 0;
+ const totalOutputTokens = data?.totalOutputTokens ?? 0;
+ const totalCacheTokens = data?.totalCacheTokens ?? 0;
+ const totalIoTokens = totalInputTokens + totalOutputTokens;
if (isLoading) {
return (
@@ -47,19 +52,27 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
const cacheCost =
(data?.tokenBreakdown?.cacheCreation?.cost ?? 0) + (data?.tokenBreakdown?.cacheRead?.cost ?? 0);
const cacheCostPercent = data?.totalCost ? Math.round((cacheCost / data.totalCost) * 100) : 0;
+ const totalTokensIncludesCache = totalCacheTokens > 0 && totalTokens > totalIoTokens;
+ const totalTokensTitle =
+ totalCacheTokens > 0 && !totalTokensIncludesCache
+ ? 'Total Tokens (I/O)'
+ : t('analyticsSummary.totalTokens');
+ const totalTokensSubtitle = totalTokensIncludesCache
+ ? `${formatNumber(totalInputTokens)} in / ${formatNumber(totalOutputTokens)} out / ${formatNumber(totalCacheTokens)} cache`
+ : t('analyticsSummary.totalTokensSubtitle', {
+ input: formatNumber(totalInputTokens),
+ output: formatNumber(totalOutputTokens),
+ });
const cards = [
{
- title: t('analyticsSummary.totalTokens'),
- value: data?.totalTokens ?? 0,
+ title: totalTokensTitle,
+ value: totalTokens,
icon: FileText,
format: (v: number) => formatNumber(v),
color: 'text-blue-600',
bgColor: 'bg-blue-100 dark:bg-blue-900/20',
- subtitle: t('analyticsSummary.totalTokensSubtitle', {
- input: formatNumber(data?.totalInputTokens ?? 0),
- output: formatNumber(data?.totalOutputTokens ?? 0),
- }),
+ subtitle: totalTokensSubtitle,
},
{
title: t('analyticsSummary.totalCost'),
diff --git a/ui/src/components/analytics/usage-trend-chart.tsx b/ui/src/components/analytics/usage-trend-chart.tsx
index e20d0303..5b031a2d 100644
--- a/ui/src/components/analytics/usage-trend-chart.tsx
+++ b/ui/src/components/analytics/usage-trend-chart.tsx
@@ -146,7 +146,6 @@ export function UsageTrendChart({
content={({ active, payload, label }) => {
if (!active || !payload || !payload.length) return null;
- const tooltipData = payload[0].payload;
return (
{label}
@@ -162,16 +161,6 @@ export function UsageTrendChart({
: `$${entry.value}`}
))}
- {'requests' in tooltipData && (
-
- Requests: {tooltipData.requests}
-
- )}
);
}}
diff --git a/ui/src/components/cliproxy/routing-guidance-card.tsx b/ui/src/components/cliproxy/routing-guidance-card.tsx
index bffc9230..ac7eaf5f 100644
--- a/ui/src/components/cliproxy/routing-guidance-card.tsx
+++ b/ui/src/components/cliproxy/routing-guidance-card.tsx
@@ -1,8 +1,12 @@
-import { useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { ArrowRightLeft, ChevronDown, ChevronUp, RefreshCw } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
-import type { CliproxyRoutingState, RoutingStrategy } from '@/lib/api-client';
+import type {
+ CliproxyRoutingState,
+ RoutingStrategy,
+ CliproxySessionAffinityState,
+} from '@/lib/api-client';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
@@ -10,10 +14,12 @@ interface RoutingGuidanceCardProps {
className?: string;
compact?: boolean;
state?: CliproxyRoutingState;
+ sessionAffinityState?: CliproxySessionAffinityState;
isLoading: boolean;
isSaving: boolean;
error?: Error | null;
onApply: (strategy: RoutingStrategy) => void;
+ onApplyAffinity: (data: { enabled: boolean; ttl?: string }) => void;
}
const STRATEGY_COPY: Record = {
@@ -31,18 +37,82 @@ export function RoutingGuidanceCard({
className,
compact = false,
state,
+ sessionAffinityState,
isLoading,
isSaving,
error,
onApply,
+ onApplyAffinity,
}: RoutingGuidanceCardProps) {
const { t } = useTranslation();
const currentStrategy = state?.strategy ?? 'round-robin';
+ const currentAffinityEnabled = sessionAffinityState?.enabled ?? false;
+ const currentAffinityTtl = sessionAffinityState?.ttl ?? '1h';
+ const sessionAffinityManageable = sessionAffinityState?.manageable ?? true;
const [selected, setSelected] = useState(currentStrategy);
+ const [selectedAffinityEnabled, setSelectedAffinityEnabled] = useState(currentAffinityEnabled);
+ const [selectedAffinityTtl, setSelectedAffinityTtl] = useState(currentAffinityTtl);
const [detailsOpen, setDetailsOpen] = useState(false);
const sourceLabel = state?.source === 'live' ? 'Live CLIProxy' : 'Saved startup default';
const saveDisabled = isLoading || isSaving || !state || selected === currentStrategy;
const detailToggleLabel = detailsOpen ? 'Hide details' : 'Show details';
+ const affinityControlDisabled = isLoading || isSaving || !!error || !sessionAffinityManageable;
+ const affinityActionLabel = sessionAffinityManageable
+ ? selectedAffinityEnabled
+ ? t('routingGuidance.disableSessionAffinity')
+ : t('routingGuidance.enableSessionAffinity')
+ : t('routingGuidance.sessionAffinityUnavailable');
+ const pendingAffinityRef = useRef<{ enabled: boolean; ttl: string } | null>(null);
+ const suppressNextAffinityBlurRef = useRef(false);
+
+ useEffect(() => {
+ setSelected(currentStrategy);
+ }, [currentStrategy]);
+
+ useEffect(() => {
+ setSelectedAffinityEnabled(currentAffinityEnabled);
+ setSelectedAffinityTtl(currentAffinityTtl);
+ }, [currentAffinityEnabled, currentAffinityTtl]);
+
+ useEffect(() => {
+ if (isSaving || !pendingAffinityRef.current) {
+ return;
+ }
+
+ const pending = pendingAffinityRef.current;
+ const succeeded =
+ pending.enabled === currentAffinityEnabled && pending.ttl === currentAffinityTtl;
+
+ if (!succeeded) {
+ setSelectedAffinityEnabled(currentAffinityEnabled);
+ setSelectedAffinityTtl(currentAffinityTtl);
+ }
+
+ pendingAffinityRef.current = null;
+ }, [isSaving, currentAffinityEnabled, currentAffinityTtl]);
+
+ const handleAffinityToggle = () => {
+ if (!sessionAffinityManageable) return;
+ const nextEnabled = !selectedAffinityEnabled;
+ const nextTtl = selectedAffinityTtl.trim() || '1h';
+ pendingAffinityRef.current = { enabled: nextEnabled, ttl: nextTtl };
+ setSelectedAffinityEnabled(nextEnabled);
+ onApplyAffinity({ enabled: nextEnabled, ttl: nextTtl });
+ };
+
+ const handleAffinityTtlBlur = () => {
+ if (!sessionAffinityManageable || !!error) return;
+ if (suppressNextAffinityBlurRef.current) {
+ suppressNextAffinityBlurRef.current = false;
+ return;
+ }
+ const nextTtl = selectedAffinityTtl.trim() || '1h';
+ if (nextTtl === currentAffinityTtl) {
+ return;
+ }
+ pendingAffinityRef.current = { enabled: selectedAffinityEnabled, ttl: nextTtl };
+ onApplyAffinity({ enabled: selectedAffinityEnabled, ttl: nextTtl });
+ };
if (compact) {
const handleApply = (s: RoutingStrategy) => {
@@ -53,57 +123,107 @@ export function RoutingGuidanceCard({
};
return (
-
-
-
-
-
+
+
+
+
+
+ Routing
+
+ {isSaving &&
}
+
+
+
+
+ {(
+ Object.entries(STRATEGY_COPY) as Array<
+ [RoutingStrategy, { title: string; description: string }]
+ >
+ ).map(([strategy, copy]) => {
+ const active = selected === strategy;
+ return (
+
+ );
+ })}
-
- Routing
-
- {isSaving &&
}
-
-
- {(
- Object.entries(STRATEGY_COPY) as Array<
- [RoutingStrategy, { title: string; description: string }]
+
+
+
+ {t('routingGuidance.sessionAffinity')}
+
+
+ {sessionAffinityManageable
+ ? t('routingGuidance.ttlBadge', { ttl: currentAffinityTtl })
+ : t('routingGuidance.localOnlySetting')}
+
+
+
+ {sessionAffinityManageable ? (
+ setSelectedAffinityTtl(event.target.value)}
+ onBlur={handleAffinityTtlBlur}
+ disabled={affinityControlDisabled}
+ />
+ ) : null}
+
+
+
+ {sessionAffinityState?.message ? (
+
+ {sessionAffinityState.message}
+
+ ) : null}
);
}
@@ -181,6 +301,55 @@ export function RoutingGuidanceCard({
{t('routingGuidance.fillFirst')}
+
+
+
{t('routingGuidance.sessionAffinity')}
+
+ {selectedAffinityEnabled
+ ? t('routingGuidance.sessionAffinityOn')
+ : t('routingGuidance.sessionAffinityOff')}
+
+ {sessionAffinityState?.ttl ? (
+
+ {t('routingGuidance.ttlBadge', { ttl: currentAffinityTtl })}
+
+ ) : null}
+ {!sessionAffinityManageable ? (
+
{t('routingGuidance.localOnly')}
+ ) : null}
+
+
+ {t('routingGuidance.sessionAffinityDescription')}
+
+
+ setSelectedAffinityTtl(event.target.value)}
+ onBlur={handleAffinityTtlBlur}
+ disabled={affinityControlDisabled}
+ />
+ {
+ suppressNextAffinityBlurRef.current = true;
+ }}
+ onClick={handleAffinityToggle}
+ disabled={affinityControlDisabled}
+ aria-label={affinityActionLabel}
+ >
+ {affinityActionLabel}
+
+
+ {sessionAffinityState?.message ? (
+
+ {sessionAffinityState.message}
+
+ ) : null}
+
+
{error ? (
{error.message}
@@ -210,6 +379,14 @@ export function RoutingGuidanceCard({
);
})}
+
+
+ {t('routingGuidance.sessionRecognitionTitle')}
+
+
+ {t('routingGuidance.sessionRecognitionDescription')}
+
+
) : null}
diff --git a/ui/src/components/compatible-cli/codex-docs-tab.tsx b/ui/src/components/compatible-cli/codex-docs-tab.tsx
index 8a88a652..12763795 100644
--- a/ui/src/components/compatible-cli/codex-docs-tab.tsx
+++ b/ui/src/components/compatible-cli/codex-docs-tab.tsx
@@ -113,13 +113,13 @@ export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) {
- {/* TODO i18n: missing key codex.builtInLabel */}
- Built-in: Use ccsxp for the CCS provider shortcut.
+ {t('codex.builtInLabel')} {t('codex.builtInCcsxpDesc')}
- {/* TODO i18n: missing key codex.nativeLabelRecipe */}
- Native: Configure the recipe below to use CLIProxy directly with{' '}
- codex.
+ {t('codex.nativeRecipeLabel')} {t('codex.nativeRecipeDesc')}
+
+
+ {t('codex.managedBridgeLabel')} {t('codex.managedBridgeDesc')}
diff --git a/ui/src/components/compatible-cli/codex-overview-tab.tsx b/ui/src/components/compatible-cli/codex-overview-tab.tsx
index 1b66f8b0..12e0f310 100644
--- a/ui/src/components/compatible-cli/codex-overview-tab.tsx
+++ b/ui/src/components/compatible-cli/codex-overview-tab.tsx
@@ -361,9 +361,7 @@ export function CodexOverviewTab({ diagnostics }: CodexOverviewTabProps) {
ccs codex --target codex
-
- {t('codex.usesTransientOverrides')}
-
+ {t('codex.transientOverridesDesc')}
>
) : (
{t('codex.unavailableNoConfig')}
diff --git a/ui/src/components/compatible-cli/codex-top-level-controls-card.tsx b/ui/src/components/compatible-cli/codex-top-level-controls-card.tsx
index 54a88bdb..87b6c3a0 100644
--- a/ui/src/components/compatible-cli/codex-top-level-controls-card.tsx
+++ b/ui/src/components/compatible-cli/codex-top-level-controls-card.tsx
@@ -130,7 +130,7 @@ function TopLevelControlsForm({
onChange={(event) =>
setDraft((current) => ({ ...current, model: event.target.value || null }))
}
- placeholder="gpt-5.4"
+ placeholder="gpt-5.5"
disabled={disabled}
/>
@@ -312,7 +312,9 @@ function TopLevelControlsForm({
className="text-[10px] uppercase tracking-[0.16em] text-muted-foreground"
>
{/* TODO i18n: missing keys codex.gpt54Selected / codex.gpt54Reference */}
- {isGpt54Selected ? 'GPT-5.4 selected' : 'GPT-5.4 reference'}
+ {isGpt54Selected
+ ? 'GPT-5.4 long-context selected'
+ : 'GPT-5.4 long-context reference'}
@@ -500,16 +502,16 @@ function TopLevelControlsForm({
{/* TODO i18n: missing key codex.docs */}
Docs
- {/* TODO i18n: missing key codex.gpt54ModelPage */}
- GPT-5.4 model page
+ {/* TODO i18n: missing key codex.gptModelGuide */}
+ GPT-5 model guide
+ typeof window === 'undefined'
+ ? true // SSR-safe default; harmless on first paint
+ : window.matchMedia(`(min-width: ${DESKTOP_BREAKPOINT_PX}px)`).matches
+ );
+
+ useEffect(() => {
+ const mql = window.matchMedia(`(min-width: ${DESKTOP_BREAKPOINT_PX}px)`);
+ const onChange = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
+ mql.addEventListener('change', onChange);
+ return () => mql.removeEventListener('change', onChange);
+ }, []);
+
+ return isDesktop;
+}
+
+interface ConfigLayoutProps {
+ /** Left rail: for multi-entity, for single-entity, omit for none. */
+ left?: ReactNode;
+ /** Middle pane: form. */
+ form: ReactNode;
+ /** Right pane: raw JSON / effective config. Omit to hide. */
+ json?: ReactNode;
+ /**
+ * Persistence key for the form↔json split ratio (per §0e). Optional —
+ * defaults to a key derived from `window.location.pathname` so each
+ * route gets its own localStorage slot automatically. Pass an explicit
+ * key only when sub-routes should share split state, or to opt out of
+ * pathname coupling. Avoid hardcoded shared keys: they cause split
+ * ratios to bleed across unrelated Config pages.
+ */
+ storageKey?: string;
+ className?: string;
+}
+
+/**
+ * ConfigLayout - Strict 3-pane shell for every Config archetype page.
+ *
+ * - >=1024px: content-fit rail (§0a, w-fit min 240 / max 360) + resizable
+ * form/json split (§0e); rail itself is NOT user-resizable
+ * - <1024px: tabs (left | form | json)
+ *
+ * Single component, prop-controlled left rail. The contract:
+ * - } …/> // multi-entity
+ * - } …/> // single-entity
+ * - // no rail
+ *
+ * The form↔json divider is user-draggable (react-resizable-panels). The
+ * chosen ratio persists in localStorage via `autoSaveId={storageKey}`. Min
+ * sizes prevent either pane from collapsing to unreadable.
+ */
+export function ConfigLayout({ left, form, json, storageKey, className }: ConfigLayoutProps) {
+ const isDesktop = useIsDesktop();
+ // Per-route default so pages get unique localStorage slots automatically
+ // (prevents the cross-page state bleed that a hardcoded shared default
+ // would cause). SSR-safe: falls back to a stable string when window is
+ // unavailable. Pages can still pass an explicit storageKey to opt out of
+ // pathname coupling (e.g. when sub-routes should share split state).
+ const effectiveKey =
+ storageKey ??
+ (typeof window === 'undefined'
+ ? 'config-layout.default'
+ : `config-layout${window.location.pathname.replace(/\//g, '.')}`);
+
+ // CRITICAL: render exactly one layout at a time. Rendering both and
+ // toggling visibility via Tailwind `hidden lg:grid` would mount two copies
+ // of every FormSection — SectionRail's scroll-spy and click-to-jump use
+ // document.getElementById() which would resolve to the (hidden) desktop
+ // copy first on mobile, breaking the rail entirely.
+ if (isDesktop) {
+ return (
+
+ {left && (
+ // §0a: rail width is content-fit within a unified envelope.
+ // Floor (min-w 240px) prevents the header from squeezing controls
+ // into a wrap. Cap (max-w 360px) prevents the rail from dominating
+ // the body when entity labels are unusually long; per-item
+ // truncation inside ListPane handles overflow beyond that.
+ // `w-fit` lets the rail expand to the largest atomic header element
+ // (title + buttons) so titles never break across two lines.
+
+ )}
+
+
+ {form}
+
+ {json && }
+ {json && (
+
+
+
+ )}
+
+
+ );
+ }
+
+ return ;
+}
+
+/**
+ * ResizeDivider - draggable handle between form and json panes (§0e).
+ *
+ * Keyboard accessible (arrow keys move 16px increments per
+ * react-resizable-panels default). Visual: thin 8px hot zone with a 1px
+ * vertical line and a centered grip glyph that brightens on hover/drag.
+ */
+function ResizeDivider() {
+ return (
+
+ {/* Thin vertical line */}
+
+ {/* Grip glyph appears on hover/drag */}
+
+
+
+
+ );
+}
+
+function MobileTabs({
+ left,
+ form,
+ json,
+ className,
+}: Pick) {
+ const tabs = [
+ left && { id: 'list', label: 'Browse', node: left },
+ { id: 'form', label: 'Configure', node: form },
+ json && { id: 'json', label: 'JSON', node: json },
+ ].filter(Boolean) as { id: string; label: string; node: ReactNode }[];
+ const [selected, setSelected] = useState(tabs[0]?.id ?? 'form');
+
+ // Derive the effective active tab during render so a parent toggling `left`
+ // or `json` (which changes the available tabs) cannot leave us pointing at
+ // an id that no longer exists. Falls back to the first available tab.
+ const active = tabs.some((t) => t.id === selected) ? selected : (tabs[0]?.id ?? 'form');
+
+ return (
+
+
+
+ {tabs.map((t) => (
+
+ {t.label}
+
+ ))}
+
+ {tabs.map((t) => (
+
+ {t.node}
+
+ ))}
+
+
+ );
+}
diff --git a/ui/src/components/config-layout/form-pane.tsx b/ui/src/components/config-layout/form-pane.tsx
new file mode 100644
index 00000000..ecb8f782
--- /dev/null
+++ b/ui/src/components/config-layout/form-pane.tsx
@@ -0,0 +1,47 @@
+import type { ReactNode } from 'react';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { cn } from '@/lib/utils';
+
+interface FormPaneProps {
+ /** Sticky header above the scrolling form body. Typically entity name + secondary actions. */
+ header?: ReactNode;
+ /** Form body — typically a stack of s. */
+ children: ReactNode;
+ /** Footer pinned at bottom. Place primary save action here. */
+ footer?: ReactNode;
+ className?: string;
+}
+
+/**
+ * FormPane - Middle pane of ConfigLayout. Holds the form.
+ *
+ * Layout: optional sticky header, scrolling body, optional sticky footer (for primary actions).
+ */
+export function FormPane({ header, children, footer, className }: FormPaneProps) {
+ return (
+
+ {header && (
+ // Sticky entity header. 1px accent strip on the top edge nods to the
+ // page's primary action (Save) which lives at the bottom — same accent.
+ // The bottom edge has a soft inset shadow so when the body scrolls
+ // under, the header reads as "elevated" rather than flat.
+
+
+ {header}
+
+ )}
+
+ {/* Body uses a faint muted wash so FormSections (bg-card) read as
+ elevated cards. Without this they would float on a same-color shell. */}
+ {children}
+
+ {footer && (
+ // Footer anchors the primary save action; muted/40 shadow pulls focus
+ // downward and keeps the action button visible while the body scrolls.
+
+ {footer}
+
+ )}
+
+ );
+}
diff --git a/ui/src/components/config-layout/form-section.tsx b/ui/src/components/config-layout/form-section.tsx
new file mode 100644
index 00000000..9bd3207d
--- /dev/null
+++ b/ui/src/components/config-layout/form-section.tsx
@@ -0,0 +1,64 @@
+import type { ReactNode } from 'react';
+import { cn } from '@/lib/utils';
+
+interface FormSectionProps {
+ /** Anchor id — must match SectionRail item id for scroll-spy. */
+ id: string;
+ title: ReactNode;
+ description?: ReactNode;
+ /** Optional trailing actions in section header. */
+ actions?: ReactNode;
+ children: ReactNode;
+ className?: string;
+}
+
+/**
+ * FormSection - Titled card group inside FormPane.
+ *
+ * The `id` prop is REQUIRED and drives SectionRail scroll-spy. Each section in a
+ * single-entity Config page should have a stable, kebab-case id like "general", "auth", "routing".
+ */
+export function FormSection({
+ id,
+ title,
+ description,
+ actions,
+ children,
+ className,
+}: FormSectionProps) {
+ return (
+
+
+
+
+
+ {title}
+
+ {description && (
+
{description}
+ )}
+
+ {actions && {actions}
}
+
+ {children}
+
+ );
+}
diff --git a/ui/src/components/config-layout/index.ts b/ui/src/components/config-layout/index.ts
new file mode 100644
index 00000000..b14c68b0
--- /dev/null
+++ b/ui/src/components/config-layout/index.ts
@@ -0,0 +1,6 @@
+export { ConfigLayout } from './config-layout';
+export { ListPane, type ListPaneItem } from './list-pane';
+export { SectionRail, type SectionRailItem } from './section-rail';
+export { FormPane } from './form-pane';
+export { FormSection } from './form-section';
+export { JsonPane, type JsonTab } from './json-pane';
diff --git a/ui/src/components/config-layout/json-pane.tsx b/ui/src/components/config-layout/json-pane.tsx
new file mode 100644
index 00000000..913f9874
--- /dev/null
+++ b/ui/src/components/config-layout/json-pane.tsx
@@ -0,0 +1,147 @@
+import { useMemo, useState, type ReactNode } from 'react';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { CopyButton } from '@/components/ui/copy-button';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { cn } from '@/lib/utils';
+
+export interface JsonTab {
+ id: string;
+ label: ReactNode;
+ /** Object/array to render. Stringified internally. */
+ data: unknown;
+}
+
+interface JsonPaneProps {
+ /** Single source — used when there's only one view. */
+ data?: unknown;
+ /** Multi-tab mode (e.g. Effective | Override | Diff). */
+ tabs?: JsonTab[];
+ title?: ReactNode;
+ /** Accept structural edits. Off by default — Phase 1 ships read-only baseline. */
+ editable?: boolean;
+ /** Called when editable=true and user commits a change. */
+ onChange?: (next: string) => void;
+ className?: string;
+}
+
+/**
+ * JsonPane - Right pane of ConfigLayout. Displays raw / effective configuration.
+ *
+ * Read-only by default. Pass `editable` to opt-in to inline editing (cliproxy uses this).
+ * Single-source via `data` OR multi-tab via `tabs` (e.g. effective / override / diff).
+ */
+export function JsonPane({
+ data,
+ tabs,
+ title = 'Configuration',
+ editable = false,
+ onChange,
+ className,
+}: JsonPaneProps) {
+ const hasTabs = tabs && tabs.length > 0;
+ const [selectedTabId, setSelectedTabId] = useState(tabs?.[0]?.id ?? 'data');
+
+ // Derive the *effective* active tab during render rather than mutating state
+ // in an effect. If the parent swaps the `tabs` array (e.g. selects a different
+ // entity), the previous selectedTabId may no longer exist — fall back to the
+ // first available tab so the pane never shows empty content.
+ const activeTab =
+ hasTabs && tabs.some((t) => t.id === selectedTabId) ? selectedTabId : (tabs?.[0]?.id ?? 'data');
+
+ return (
+ // Outer shell: muted wash differentiates the json pane from the form pane
+ // (bg-card) so the two sibling panes are visually distinct without a
+ // hard divider line. Maintains the Pampas/Crail palette — no new colors.
+
+
+ {/* 1px Crail strip at the top edge — visually links to FormPane's header strip. */}
+
+
+
+
+ {title}
+
+
+ {editable ? 'editable' : 'read only'}
+
+
+
+ t.id === activeTab)?.data ?? {}, null, 2)
+ : JSON.stringify(data ?? {}, null, 2)
+ }
+ />
+
+
+
+ {hasTabs ? (
+
+
+ {tabs.map((t) => (
+
+ {t.label}
+
+ ))}
+
+ {tabs.map((t) => (
+
+
+
+ ))}
+
+ ) : (
+
+ )}
+
+ );
+}
+
+interface JsonViewProps {
+ data: unknown;
+ editable: boolean;
+ onChange?: (next: string) => void;
+}
+
+function JsonView({ data, editable, onChange }: JsonViewProps) {
+ const text = useMemo(() => JSON.stringify(data ?? {}, null, 2), [data]);
+
+ if (editable) {
+ // `key={text}` forces React to remount the textarea when the underlying data
+ // changes (e.g. parent swaps the selected entity). Without this, an
+ // uncontrolled textarea retains the prior value and onBlur saves stale text.
+ return (
+
+
+
+ setExtraModelsTouched(true),
+ })}
+ placeholder="model-a,model-b,model-c"
+ />
+
+ Comma-separated list of additional models available via this provider. These are
+ synced to CLIProxy alongside the default model.
+
+
+
{/* Model Mapping Section */}
=> !!w)
.filter(
(w, index, arr) =>
@@ -459,7 +465,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
},
{ label: t('quotaTooltip.chat'), snapshot: quota.snapshots.chat },
{ label: t('quotaTooltip.completions'), snapshot: quota.snapshots.completions },
- ];
+ ].filter(({ snapshot }) => snapshot.reported !== false);
const effectiveResetTime = quota.quotaResetDate ?? resetTime;
const planLabel = formatPlanLabel(quota.planType);
diff --git a/ui/src/components/ui/masked-input.tsx b/ui/src/components/ui/masked-input.tsx
index 9b18e300..57f8ad18 100644
--- a/ui/src/components/ui/masked-input.tsx
+++ b/ui/src/components/ui/masked-input.tsx
@@ -1,30 +1,76 @@
import { useState } from 'react';
+import { Eye, EyeOff, Lock } from 'lucide-react';
import { Input } from '@/components/ui/input';
-import { Button } from '@/components/ui/button';
-import { Eye, EyeOff } from 'lucide-react';
+import { cn } from '@/lib/utils';
-interface MaskedInputProps extends React.InputHTMLAttributes {
+// `type` is intentionally omitted from the prop surface so callers cannot
+// override the `password`/`text` toggle and accidentally render a credential
+// in plaintext. The component is the sole authority on the input type.
+interface MaskedInputProps extends Omit, 'type'> {
label?: string;
+ /**
+ * Hide the "sensitive" pill on the label row. Defaults to false (pill shown).
+ * Use only when the surrounding context already signals sensitivity.
+ */
+ hideSensitivePill?: boolean;
}
-export function MaskedInput({ label, ...props }: MaskedInputProps) {
- const [visible, setVisible] = useState(false);
+/**
+ * MaskedInput - Sensitive text input per design-system.md §5g.
+ *
+ * Treatment: Lock glyph prefixing the label · "sensitive" accent pill on the
+ * label row · by default with a reveal/hide eye
+ * toggle on the right edge · accent focus ring (the only place the default
+ * focus ring is overridden).
+ *
+ * Consumers: API token / OAuth secret / WebSearch API key / env-block secret
+ * fields across cliproxy / accounts / settings / profile editor.
+ */
+export function MaskedInput({
+ label,
+ hideSensitivePill = false,
+ className,
+ ...props
+}: MaskedInputProps) {
+ const [revealed, setRevealed] = useState(false);
return (
-
- {label &&
}
+
+ {label && (
+
+
+ {!hideSensitivePill && (
+
+ sensitive
+
+ )}
+
+ )}
-
-
+ setVisible(!visible)}
- tabIndex={-1}
+ onClick={() => setRevealed((v) => !v)}
+ aria-label={revealed ? 'Hide value' : 'Reveal value'}
+ aria-pressed={revealed}
+ className="absolute right-1.5 top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent/60"
>
- {visible ? : }
-
+ {revealed ? : }
+
);
diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts
index ff1e9354..98c23f55 100644
--- a/ui/src/hooks/use-cliproxy.ts
+++ b/ui/src/hooks/use-cliproxy.ts
@@ -11,6 +11,7 @@ import {
type UpdateVariant,
type CreatePreset,
type RoutingStrategy,
+ type CliproxySessionAffinityApplyResult,
} from '@/lib/api-client';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
@@ -18,6 +19,7 @@ import { useTranslation } from 'react-i18next';
function invalidateCliproxyRoutingQueries(queryClient: ReturnType
): void {
queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] });
queryClient.invalidateQueries({ queryKey: ['cliproxy-models'] });
+ queryClient.invalidateQueries({ queryKey: ['cliproxy-session-affinity'] });
}
function invalidateCliproxyAccountQueries(queryClient: ReturnType): void {
@@ -74,6 +76,36 @@ export function useUpdateCliproxyRoutingStrategy() {
});
}
+export function useCliproxySessionAffinity() {
+ return useQuery({
+ queryKey: ['cliproxy-session-affinity'],
+ queryFn: () => api.cliproxy.getSessionAffinity(),
+ });
+}
+
+export function useUpdateCliproxySessionAffinity() {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: (data: { enabled: boolean; ttl?: string }) =>
+ api.cliproxy.updateSessionAffinity(data),
+ onSuccess: (result: CliproxySessionAffinityApplyResult) => {
+ queryClient.setQueryData(['cliproxy-session-affinity'], result);
+ queryClient.invalidateQueries({ queryKey: ['cliproxy-session-affinity'] });
+ const stateLabel = result.enabled
+ ? t('routingGuidance.sessionAffinityEnabled')
+ : t('routingGuidance.sessionAffinityDisabled');
+ toast.success(
+ result.message || t('toasts.sessionAffinityUpdated', { state: stateLabel.toLowerCase() })
+ );
+ },
+ onError: (error: Error) => {
+ toast.error(error.message);
+ },
+ });
+}
+
export function useCreateVariant() {
const queryClient = useQueryClient();
const { t } = useTranslation();
diff --git a/ui/src/hooks/use-usage.ts b/ui/src/hooks/use-usage.ts
index 709da6b6..9c929fdd 100644
--- a/ui/src/hooks/use-usage.ts
+++ b/ui/src/hooks/use-usage.ts
@@ -95,6 +95,7 @@ export interface UsageInsights {
export interface Session {
sessionId: string;
projectPath: string;
+ tokens?: number;
inputTokens: number;
outputTokens: number;
cost: number;
@@ -122,7 +123,6 @@ export interface MonthlyUsage {
export interface UsageQueryOptions {
startDate?: Date;
endDate?: Date;
- profile?: string;
limit?: number;
offset?: number;
}
@@ -145,48 +145,49 @@ function formatDateForApi(date: Date): string {
return `${year}${month}${day}`;
}
+function buildUsageUrl(path: string, params: URLSearchParams): string {
+ const query = params.toString();
+ return query ? `${path}?${query}` : path;
+}
+
export const usageApi = {
summary: (options?: UsageQueryOptions) => {
const params = new URLSearchParams();
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
- if (options?.profile) params.append('profile', options.profile);
- return request(`/usage/summary?${params}`);
+ return request(buildUsageUrl('/usage/summary', params));
},
trends: (options?: UsageQueryOptions) => {
const params = new URLSearchParams();
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
- if (options?.profile) params.append('profile', options.profile);
- return request(`/usage/daily?${params}`);
+ return request(buildUsageUrl('/usage/daily', params));
},
hourly: (options?: UsageQueryOptions) => {
const params = new URLSearchParams();
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
- return request(`/usage/hourly?${params}`);
+ return request(buildUsageUrl('/usage/hourly', params));
},
models: (options?: UsageQueryOptions) => {
const params = new URLSearchParams();
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
- if (options?.profile) params.append('profile', options.profile);
- return request(`/usage/models?${params}`);
+ return request(buildUsageUrl('/usage/models', params));
},
sessions: (options?: UsageQueryOptions) => {
const params = new URLSearchParams();
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
- if (options?.profile) params.append('profile', options.profile);
if (options?.limit) params.append('limit', options.limit.toString());
if (options?.offset) params.append('offset', options.offset.toString());
- return request(`/usage/sessions?${params}`);
+ return request(buildUsageUrl('/usage/sessions', params));
},
- monthly: (months?: number, profile?: string) => {
+ monthly: (options?: UsageQueryOptions) => {
const params = new URLSearchParams();
- if (months) params.append('months', months.toString());
- if (profile) params.append('profile', profile);
- return request(`/usage/monthly?${params}`);
+ if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
+ if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
+ return request(buildUsageUrl('/usage/monthly', params));
},
/** Clear server-side usage cache and force fresh data fetch */
refresh: async (): Promise => {
@@ -205,8 +206,7 @@ export const usageApi = {
const params = new URLSearchParams();
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
- if (options?.profile) params.append('profile', options.profile);
- return request(`/usage/insights?${params}`);
+ return request(buildUsageUrl('/usage/insights', params));
},
};
diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts
index 38e43778..d3338297 100644
--- a/ui/src/lib/api-client.ts
+++ b/ui/src/lib/api-client.ts
@@ -336,6 +336,7 @@ export interface CreateProfile {
baseUrl: string;
apiKey: string;
model?: string;
+ extraModels?: string;
opusModel?: string;
sonnetModel?: string;
haikuModel?: string;
@@ -346,6 +347,7 @@ export interface UpdateProfile {
baseUrl?: string;
apiKey?: string;
model?: string;
+ extraModels?: string;
opusModel?: string;
sonnetModel?: string;
haikuModel?: string;
@@ -519,6 +521,20 @@ export interface CliproxyRoutingApplyResult extends CliproxyRoutingState {
applied: 'live' | 'live-and-config' | 'config-only';
}
+export interface CliproxySessionAffinityState {
+ enabled?: boolean;
+ ttl?: string;
+ source: 'config' | 'unsupported';
+ target: 'local' | 'remote';
+ reachable: boolean;
+ manageable: boolean;
+ message?: string;
+}
+
+export interface CliproxySessionAffinityApplyResult extends CliproxySessionAffinityState {
+ applied: 'config-and-live' | 'config-only' | 'unsupported';
+}
+
/** Auth file info for Config tab */
export interface AuthFile {
name: string;
@@ -643,7 +659,11 @@ export interface QuotaResult {
/** Codex rate limit window */
export interface CodexQuotaWindow {
- /** Window label: "Primary", "Secondary", "Code Review (Primary)", "Code Review (Secondary)" */
+ /**
+ * Window label, e.g.: "Primary", "Secondary",
+ * "Code Review (Primary)", "Code Review (Secondary)",
+ * or "GPT-5.3-Codex-Spark (Primary)" for additional rate limits.
+ */
label: string;
/** Percentage used (0-100) */
usedPercent: number;
@@ -653,6 +673,18 @@ export interface CodexQuotaWindow {
resetAfterSeconds: number | null;
/** ISO timestamp when quota resets, null if unknown */
resetAt: string | null;
+ /**
+ * Window category indicating the bucket this window belongs to.
+ * Optional for back-compat with cached data emitted before this field existed.
+ * - 'usage' -> standard rate_limit usage windows
+ * - 'code-review' -> code_review_rate_limit windows
+ * - 'additional' -> additional_rate_limits[] windows (e.g. GPT-5.3 Codex Spark)
+ */
+ category?: 'usage' | 'code-review' | 'additional';
+ /** Cadence of the window: '5h' = primary, 'weekly' = secondary. Optional for legacy data. */
+ cadence?: '5h' | 'weekly';
+ /** Raw upstream feature label (e.g. 'GPT-5.3-Codex-Spark', 'Code Review'); absent for plain usage windows. */
+ featureLabel?: string;
}
/** Core Codex usage window (5h/weekly) extracted from raw windows */
@@ -830,6 +862,8 @@ export interface GeminiCliQuotaResult {
/** GitHub Copilot quota snapshot */
export interface GhcpQuotaSnapshot {
+ /** False when upstream omitted this quota category */
+ reported?: boolean;
/** Total quota allocation for this category */
entitlement: number;
/** Remaining quota count */
@@ -1251,6 +1285,13 @@ export const api = {
method: 'PUT',
body: JSON.stringify({ value: strategy }),
}),
+ getSessionAffinity: () =>
+ request('/cliproxy/routing/session-affinity'),
+ updateSessionAffinity: (data: { enabled: boolean; ttl?: string }) =>
+ request('/cliproxy/routing/session-affinity', {
+ method: 'PUT',
+ body: JSON.stringify(data),
+ }),
aiProviders: {
list: () => request('/cliproxy/ai-providers'),
create: (family: AiProviderFamilyId, data: UpsertAiProviderEntryInput) =>
diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts
index 53f26f36..7c25d987 100644
--- a/ui/src/lib/i18n.ts
+++ b/ui/src/lib/i18n.ts
@@ -1626,6 +1626,9 @@ const resources = {
codeReview5h: 'Code review (5h)',
codeReviewWeekly: 'Code review (weekly)',
codeReview: 'Code review',
+ codexAdditional5h: '{{name}} (5h)',
+ codexAdditionalWeekly: '{{name}} (weekly)',
+ codexAdditional: '{{name}}',
},
sponsorButton: {
title: 'Sponsor this project on GitHub',
@@ -1820,6 +1823,22 @@ const resources = {
fillFirst: 'Fill first keeps backup accounts cold until they are needed.',
routingStrategy: 'Routing strategy',
optionalRouting: 'Optional routing',
+ sessionAffinity: 'Session affinity',
+ sessionAffinityOn: 'On',
+ sessionAffinityOff: 'Off',
+ sessionAffinityEnabled: 'Enabled',
+ sessionAffinityDisabled: 'Disabled',
+ enableSessionAffinity: 'Enable session affinity',
+ disableSessionAffinity: 'Disable session affinity',
+ sessionAffinityUnavailable: 'Session affinity unavailable',
+ localOnly: 'Local only',
+ localOnlySetting: 'Local-only setting',
+ ttlBadge: 'TTL {{ttl}}',
+ sessionAffinityDescription:
+ 'Keep one conversation pinned to the same account when possible. CLIProxy prefers explicit session or thread identifiers when clients send them, then falls back to request metadata or the opening prompt history when it has to infer a stable key.',
+ sessionRecognitionTitle: 'Session recognition',
+ sessionRecognitionDescription:
+ 'CCS does not promise one universal precedence order here. In practice, upstream backends prefer explicit session or thread IDs first, then fall back to metadata fields and finally a hash based on the opening prompt history.',
},
extendedContext: {
extendedContext: 'Extended Context',
@@ -1911,7 +1930,7 @@ const resources = {
overview: 'Overview',
docs: 'Docs',
nativeCodexRuntime: 'Native Codex Runtime',
- ccsCodexProvider: 'CCS Codex provider / bridge',
+ ccsCodexProvider: 'Cliproxy shortcut / managed bridge',
codexDocs: 'Codex docs',
howCodexWorks: 'How Codex works in CCS',
supportedFlows: 'Supported flows',
@@ -1922,7 +1941,7 @@ const resources = {
nativeConfigDesc: 'ccs-codex and ccsx launch native Codex using your saved defaults.',
transientOverridesLabel: 'Transient overrides:',
transientOverridesDesc:
- 'ccsxp (or ccs codex --target codex) uses the CCS provider shortcut.',
+ 'ccsxp stays on native Codex and prepends the cliproxy provider override; ccs codex --target codex remains the explicit CCS-managed bridge.',
cliproxyDefaultLabel: 'CLIProxy default:',
cliproxyDefaultDesc:
'To make plain codex use CLIProxy, set model_provider = "cliproxy" and add the recipe below.',
@@ -1936,13 +1955,16 @@ const resources = {
installDirectory: 'Install directory',
versionLabel: 'Version',
nativeAliases: 'Native aliases',
- ccsProviderShortcut: 'CCS provider shortcut',
+ ccsProviderShortcut: 'Cliproxy shortcut',
configOverrideSupport: '--config override support',
available: 'Available',
missing: 'Missing',
cliproxyNativeCodex: 'CLIProxy-backed native Codex',
builtInLabel: 'Built-in:',
- builtInCcsxpDesc: 'Use ccsxp for the CCS provider shortcut.',
+ builtInCcsxpDesc: 'Use ccsxp for native Codex with the cliproxy provider shortcut.',
+ managedBridgeLabel: 'Managed bridge:',
+ managedBridgeDesc:
+ 'Use ccs codex --target codex when you want the stricter CCS-managed route explicitly.',
nativeRecipeLabel: 'Native:',
nativeRecipeDesc: 'Configure the recipe below to use CLIProxy directly with codex.',
saveProviderNamedCliproxy:
@@ -1951,7 +1973,7 @@ const resources = {
exportCliproxyApiKey:
'Export CLIPROXY_API_KEY in your shell before launching native Codex.',
noConfigOverrides:
- 'This Codex build can still use the native path, but CCS-backed Codex routing via ccsxp or ccs codex --target codex stays unavailable until the detected Codex binary exposes --config overrides.',
+ 'This Codex build can still use the plain native path, but ccsxp and the explicit CCS-managed Codex bridge stay unavailable until the detected Codex binary exposes --config overrides.',
configFile: 'Config file',
path: 'Path',
resolved: 'Resolved',
@@ -1972,12 +1994,12 @@ const resources = {
officialChannels: 'Official Channels',
codexCli: 'Codex CLI',
openNativeCodex: 'Open native Codex',
- runBuiltInCodex: 'Run built-in Codex on Codex',
- runBuiltInCodexExplicit: 'Run built-in Codex on Codex (explicit)',
+ runBuiltInCodex: 'Run native Codex with the cliproxy shortcut',
+ runBuiltInCodexExplicit: 'Run the CCS-managed Codex bridge explicitly',
openCodexDashboard: 'Open Codex dashboard',
nativeShortAlias: 'Native short alias',
- ccsCodexShortcut: 'CCS Codex shortcut',
- explicitProviderRoute: 'Explicit provider route',
+ ccsCodexShortcut: 'Cliproxy shortcut',
+ explicitProviderRoute: 'Managed bridge route',
status: 'Status',
profiles: 'Profiles',
createNewProfile: 'Create new profile',
@@ -2167,6 +2189,7 @@ const resources = {
accountsUpdated: 'Accounts updated',
noProfilesToSync: 'No profiles to sync',
syncFailed: 'Sync failed: {{error}}',
+ sessionAffinityUpdated: 'Session affinity {{state}}.',
providerAuthSuccess: '{{provider}} authentication successful',
providerDeviceCodeInCallback: 'Provider returned Device Code flow in callback mode',
providerAuthTimeout: 'Authentication timed out. Please try again.',
@@ -4095,6 +4118,9 @@ const resources = {
codeReview5h: '代码审查(5 小时)',
codeReviewWeekly: '代码审查(每周)',
codeReview: '代码审查',
+ codexAdditional5h: '{{name}}(5 小时)',
+ codexAdditionalWeekly: '{{name}}(每周)',
+ codexAdditional: '{{name}}',
},
sponsorButton: {
title: '在 GitHub 上赞助此项目',
@@ -4280,6 +4306,22 @@ const resources = {
fillFirst: '优先填满模式让备用账号保持冷启动直到需要时。',
routingStrategy: '路由策略',
optionalRouting: '可选路由',
+ sessionAffinity: '会话粘性',
+ sessionAffinityOn: '开启',
+ sessionAffinityOff: '关闭',
+ sessionAffinityEnabled: '已启用',
+ sessionAffinityDisabled: '已禁用',
+ enableSessionAffinity: '启用会话粘性',
+ disableSessionAffinity: '禁用会话粘性',
+ sessionAffinityUnavailable: '会话粘性不可用',
+ localOnly: '仅本地',
+ localOnlySetting: '仅限本地设置',
+ ttlBadge: 'TTL {{ttl}}',
+ sessionAffinityDescription:
+ '尽量将同一对话固定到同一个账号。CLIProxy 会优先使用客户端显式提供的会话或线程标识;如果没有,再回退到请求元数据或开场提示历史来推断稳定键。',
+ sessionRecognitionTitle: '会话识别',
+ sessionRecognitionDescription:
+ 'CCS 不承诺所有后端都使用同一优先级顺序。通常上游会优先使用显式会话或线程 ID,然后回退到元数据字段,最后再回退到基于开场提示历史的哈希。',
},
extendedContext: {
extendedContext: '扩展上下文',
@@ -4366,7 +4408,7 @@ const resources = {
overview: '概览',
docs: '文档',
nativeCodexRuntime: '原生 Codex 运行时',
- ccsCodexProvider: 'CCS Codex 提供商 / 桥接',
+ ccsCodexProvider: 'Cliproxy 快捷方式 / 托管桥接',
codexDocs: 'Codex 文档',
supportedFlows: '支持的工作流',
twoSupportedPaths: '两种支持路径:',
@@ -4379,8 +4421,8 @@ const resources = {
officialChannels: '官方渠道',
codexCli: 'Codex CLI',
openNativeCodex: '打开原生 Codex',
- runBuiltInCodex: '在 Codex 上运行内置命令',
- runBuiltInCodexExplicit: '在 Codex 上运行内置命令(显式)',
+ runBuiltInCodex: '通过 cliproxy 快捷方式运行原生 Codex',
+ runBuiltInCodexExplicit: '显式运行 CCS 托管 Codex 桥接',
openCodexDashboard: '打开 Codex Dashboard',
status: '状态',
profiles: '配置',
@@ -4459,7 +4501,8 @@ const resources = {
nativeConfigLabel: '原生配置:',
nativeConfigDesc: 'ccs-codex 和 ccsx 会使用你保存的默认值启动原生 Codex。',
transientOverridesLabel: '临时覆盖:',
- transientOverridesDesc: 'ccsxp(或 ccs codex --target codex)会使用 CCS 提供商快捷方式。',
+ transientOverridesDesc:
+ 'ccsxp 会保持原生 Codex,只额外加上 cliproxy provider 覆盖;ccs codex --target codex 仍然是显式的 CCS 托管桥接。',
cliproxyDefaultLabel: 'CLIProxy 默认:',
cliproxyDefaultDesc:
'如果你想让普通的 codex 走 CLIProxy,请设置 model_provider = "cliproxy",并加入下面的配方。',
@@ -4471,13 +4514,15 @@ const resources = {
installDirectory: '安装目录',
versionLabel: '版本',
nativeAliases: '原生别名',
- ccsProviderShortcut: 'CCS 提供商快捷方式',
+ ccsProviderShortcut: 'Cliproxy 快捷方式',
configOverrideSupport: '--config 覆盖支持',
available: '可用',
missing: '缺失',
cliproxyNativeCodex: '由 CLIProxy 支持的原生 Codex',
builtInLabel: '内置:',
- builtInCcsxpDesc: '使用 ccsxp 作为 CCS 提供商快捷方式。',
+ builtInCcsxpDesc: '使用 ccsxp 通过 cliproxy provider 快捷方式启动原生 Codex。',
+ managedBridgeLabel: '托管桥接:',
+ managedBridgeDesc: '如果你想显式走更严格的 CCS 托管路由,请使用 ccs codex --target codex。',
nativeRecipeLabel: '原生:',
nativeRecipeDesc: '按下面的配方配置,即可让 codex 直接走 CLIProxy。',
saveProviderNamedCliproxy:
@@ -4485,7 +4530,7 @@ const resources = {
inTopLevelSetDefault: '在顶层设置中,将 Default provider 设为 cliproxy。',
exportCliproxyApiKey: '在启动原生 Codex 之前,在 shell 中导出 CLIPROXY_API_KEY。',
noConfigOverrides:
- '这个 Codex 版本仍然可以走原生路径,但在检测到的二进制支持 --config 覆盖之前,ccsxp 或 ccs codex --target codex 这样的 CCS 路由不可用。',
+ '这个 Codex 版本仍然可以走纯原生路径,但在检测到的二进制支持 --config 覆盖之前,ccsxp 和显式的 CCS 托管 Codex 桥接都不可用。',
configFile: '配置文件',
path: '路径',
resolved: '已解析',
@@ -4612,6 +4657,7 @@ const resources = {
accountsUpdated: '账号已更新',
noProfilesToSync: '没有可同步的配置',
syncFailed: '同步失败:{{error}}',
+ sessionAffinityUpdated: '会话粘性已{{state}}。',
providerAuthSuccess: '{{provider}} 认证成功',
providerDeviceCodeInCallback: '提供商在回调模式中返回了设备码流程',
providerAuthTimeout: '认证超时,请重试。',
@@ -6624,6 +6670,9 @@ const resources = {
codeReview5h: 'Đánh giá mã (5h)',
codeReviewWeekly: 'Đánh giá mã (hàng tuần)',
codeReview: 'Đánh giá mã',
+ codexAdditional5h: '{{name}} (5h)',
+ codexAdditionalWeekly: '{{name}} (hàng tuần)',
+ codexAdditional: '{{name}}',
},
sponsorButton: {
title: 'Tài trợ dự án này trên GitHub',
@@ -6810,6 +6859,22 @@ const resources = {
fillFirst: 'Fill-first giữ tài khoản dự phòng cho đến khi cần thiết.',
routingStrategy: 'Chiến lược định tuyến',
optionalRouting: 'Định tuyến tùy chọn',
+ sessionAffinity: 'Ghim phiên',
+ sessionAffinityOn: 'Bật',
+ sessionAffinityOff: 'Tắt',
+ sessionAffinityEnabled: 'bật',
+ sessionAffinityDisabled: 'tắt',
+ enableSessionAffinity: 'Bật ghim phiên',
+ disableSessionAffinity: 'Tắt ghim phiên',
+ sessionAffinityUnavailable: 'Ghim phiên không khả dụng',
+ localOnly: 'Chỉ cục bộ',
+ localOnlySetting: 'Thiết lập chỉ cục bộ',
+ ttlBadge: 'TTL {{ttl}}',
+ sessionAffinityDescription:
+ 'Giữ một cuộc hội thoại trên cùng một tài khoản khi có thể. CLIProxy ưu tiên mã phiên hoặc luồng mà client gửi rõ ràng; nếu không có, nó sẽ dùng metadata của request hoặc lịch sử prompt mở đầu để suy ra khóa ổn định.',
+ sessionRecognitionTitle: 'Nhận diện phiên',
+ sessionRecognitionDescription:
+ 'CCS không cam kết một thứ tự ưu tiên chung cho mọi backend. Trên thực tế, backend upstream thường ưu tiên session hoặc thread id tường minh, rồi mới fallback sang metadata và cuối cùng là hàm băm của lịch sử prompt mở đầu.',
},
extendedContext: {
extendedContext: 'Ngữ cảnh mở rộng',
@@ -6898,7 +6963,7 @@ const resources = {
overview: 'Tổng quan',
docs: 'Tài liệu',
nativeCodexRuntime: 'Codex Runtime gốc',
- ccsCodexProvider: 'CCS Codex provider / bridge',
+ ccsCodexProvider: 'Lối tắt cliproxy / cầu nối được quản lý',
codexDocs: 'Tài liệu Codex',
supportedFlows: 'Các luồng được hỗ trợ',
twoSupportedPaths: 'Hai đường dẫn được hỗ trợ:',
@@ -6911,8 +6976,8 @@ const resources = {
officialChannels: 'Kênh chính thức',
codexCli: 'Codex CLI',
openNativeCodex: 'Mở Codex gốc',
- runBuiltInCodex: 'Chạy Codex tích hợp trên Codex',
- runBuiltInCodexExplicit: 'Chạy Codex tích hợp trên Codex (rõ ràng)',
+ runBuiltInCodex: 'Chạy Codex gốc với lối tắt cliproxy',
+ runBuiltInCodexExplicit: 'Chạy rõ ràng cầu nối Codex do CCS quản lý',
openCodexDashboard: 'Mở dashboard Codex',
status: 'Trạng thái',
profiles: 'Hồ sơ',
@@ -6993,7 +7058,7 @@ const resources = {
nativeConfigDesc: 'ccs-codex và ccsx khởi chạy Codex gốc bằng các mặc định bạn đã lưu.',
transientOverridesLabel: 'Ghi đè tạm thời:',
transientOverridesDesc:
- 'ccsxp (hoặc ccs codex --target codex) dùng đường tắt nhà cung cấp CCS.',
+ 'ccsxp vẫn chạy Codex gốc và chỉ thêm ghi đè provider cliproxy; ccs codex --target codex vẫn là cầu nối CCS được quản lý rõ ràng.',
cliproxyDefaultLabel: 'Mặc định CLIProxy:',
cliproxyDefaultDesc:
'Để lệnh codex thông thường đi qua CLIProxy, hãy đặt model_provider = "cliproxy" và dùng công thức bên dưới.',
@@ -7005,20 +7070,23 @@ const resources = {
installDirectory: 'Thư mục cài đặt',
versionLabel: 'Phiên bản',
nativeAliases: 'Bí danh gốc',
- ccsProviderShortcut: 'Lối tắt nhà cung cấp CCS',
+ ccsProviderShortcut: 'Lối tắt cliproxy',
configOverrideSupport: 'Hỗ trợ ghi đè --config',
available: 'Có sẵn',
missing: 'Thiếu',
cliproxyNativeCodex: 'Codex gốc dùng CLIProxy',
builtInLabel: 'Tích hợp:',
- builtInCcsxpDesc: 'Dùng ccsxp cho lối tắt nhà cung cấp CCS.',
+ builtInCcsxpDesc: 'Dùng ccsxp để chạy Codex gốc với lối tắt provider cliproxy.',
+ managedBridgeLabel: 'Cầu nối được quản lý:',
+ managedBridgeDesc:
+ 'Dùng ccs codex --target codex khi bạn muốn tuyến CCS được quản lý chặt hơn một cách tường minh.',
nativeRecipeLabel: 'Gốc:',
nativeRecipeDesc: 'Cấu hình công thức bên dưới để dùng CLIProxy trực tiếp với codex.',
saveProviderNamedCliproxy: 'Lưu một provider tên cliproxy với base URL và env key ở trên.',
inTopLevelSetDefault: 'Trong Top-level settings, đặt Default provider là cliproxy.',
exportCliproxyApiKey: 'Xuất CLIPROXY_API_KEY trong shell trước khi chạy Codex gốc.',
noConfigOverrides:
- 'Bản Codex này vẫn có thể dùng đường gốc, nhưng định tuyến Codex qua CCS như ccsxp hoặc ccs codex --target codex sẽ không hoạt động cho đến khi binary hỗ trợ ghi đè --config.',
+ 'Bản Codex này vẫn có thể dùng đường gốc thuần, nhưng ccsxp và cầu nối Codex do CCS quản lý rõ ràng sẽ chưa dùng được cho đến khi binary hỗ trợ ghi đè --config.',
configFile: 'Tệp cấu hình',
path: 'Đường dẫn',
resolved: 'Đã phân giải',
@@ -7145,6 +7213,7 @@ const resources = {
accountsUpdated: 'Tài khoản đã được cập nhật',
noProfilesToSync: 'Không có hồ sơ để đồng bộ',
syncFailed: 'Đồng bộ thất bại: {{error}}',
+ sessionAffinityUpdated: 'Ghim phiên đã {{state}}.',
providerAuthSuccess: 'Xác thực {{provider}} thành công',
providerDeviceCodeInCallback: 'Nhà cung cấp trả về Device Code flow trong chế độ callback',
providerAuthTimeout: 'Đã hết thời gian xác thực. Vui lòng thử lại.',
@@ -9192,7 +9261,7 @@ const resources = {
overview: '概要',
docs: 'ドキュメント',
nativeCodexRuntime: 'ネイティブ Codex ランタイム',
- ccsCodexProvider: 'CCS Codex プロバイダー / ブリッジ',
+ ccsCodexProvider: 'cliproxy ショートカット / 管理ブリッジ',
codexDocs: 'Codex ドキュメント',
supportedFlows: '対応フロー',
twoSupportedPaths: '2つのサポートパス:',
@@ -9205,8 +9274,8 @@ const resources = {
officialChannels: '公式チャンネル',
codexCli: 'Codex CLI',
openNativeCodex: 'ネイティブ Codex を開く',
- runBuiltInCodex: '内蔵 Codex で Codex を実行',
- runBuiltInCodexExplicit: '内蔵 Codex で Codex を実行(明示)',
+ runBuiltInCodex: 'cliproxy ショートカット付きでネイティブ Codex を実行',
+ runBuiltInCodexExplicit: 'CCS 管理 Codex ブリッジを明示的に実行',
openCodexDashboard: 'Codex ダッシュボードを開く',
status: 'ステータス',
profiles: 'プロファイル',
@@ -9287,7 +9356,7 @@ const resources = {
nativeConfigDesc: 'ccs-codex と ccsx は保存済みデフォルトでネイティブ Codex を起動します。',
transientOverridesLabel: '一時オーバーライド:',
transientOverridesDesc:
- 'ccsxp(または ccs codex --target codex)は CCS プロバイダーのショートカットを使います。',
+ 'ccsxp はネイティブ Codex のまま cliproxy プロバイダー上書きだけを追加し、ccs codex --target codex は明示的な CCS 管理ブリッジのままです。',
cliproxyDefaultLabel: 'CLIProxy デフォルト:',
cliproxyDefaultDesc:
'通常の codex を CLIProxy 経由にするには、model_provider = "cliproxy" を設定し、下のレシピを追加します。',
@@ -9299,13 +9368,17 @@ const resources = {
installDirectory: 'インストール先',
versionLabel: 'バージョン',
nativeAliases: 'ネイティブ別名',
- ccsProviderShortcut: 'CCS プロバイダーショートカット',
+ ccsProviderShortcut: 'cliproxy ショートカット',
configOverrideSupport: '--config オーバーライド対応',
available: '利用可能',
missing: '不足',
cliproxyNativeCodex: 'CLIProxy 経由のネイティブ Codex',
builtInLabel: '内蔵:',
- builtInCcsxpDesc: 'CCS プロバイダーショートカットとして ccsxp を使用します。',
+ builtInCcsxpDesc:
+ 'ccsxp を使うと、cliproxy プロバイダーショートカット付きでネイティブ Codex を起動します。',
+ managedBridgeLabel: '管理ブリッジ:',
+ managedBridgeDesc:
+ 'より厳格な CCS 管理ルートを明示的に使いたい場合は ccs codex --target codex を使います。',
nativeRecipeLabel: 'ネイティブ:',
nativeRecipeDesc: '下のレシピを設定すると codex を直接 CLIProxy 経由で利用できます。',
saveProviderNamedCliproxy:
@@ -9314,7 +9387,7 @@ const resources = {
exportCliproxyApiKey:
'ネイティブ Codex を起動する前に shell で CLIPROXY_API_KEY を export してください。',
noConfigOverrides:
- 'この Codex ビルドでもネイティブパスは使えますが、ccsxp や ccs codex --target codex などの CCS 側ルーティングは、検出された Codex バイナリが --config オーバーライドに対応するまで利用できません。',
+ 'この Codex ビルドでも純粋なネイティブパスは使えますが、検出された Codex バイナリが --config オーバーライドに対応するまで、ccsxp と明示的な CCS 管理 Codex ブリッジは利用できません。',
configFile: '設定ファイル',
path: 'パス',
resolved: '解決先',
@@ -9753,6 +9826,9 @@ const resources = {
codeReview5h: 'コードレビュー(5時間)',
codeReviewWeekly: 'コードレビュー(週次)',
codeReview: 'コードレビュー',
+ codexAdditional5h: '{{name}}(5時間)',
+ codexAdditionalWeekly: '{{name}}(週次)',
+ codexAdditional: '{{name}}',
},
rawEditorSection: {
rawConfig: 'Raw 設定',
@@ -9765,6 +9841,22 @@ const resources = {
fillFirst: 'Fill first は、バックアップアカウントが必要になるまで待機させます。',
routingStrategy: 'ルーティング戦略',
optionalRouting: 'オプションのルーティング',
+ sessionAffinity: 'セッション固定',
+ sessionAffinityOn: 'オン',
+ sessionAffinityOff: 'オフ',
+ sessionAffinityEnabled: '有効',
+ sessionAffinityDisabled: '無効',
+ enableSessionAffinity: 'セッション固定を有効化',
+ disableSessionAffinity: 'セッション固定を無効化',
+ sessionAffinityUnavailable: 'セッション固定は利用できません',
+ localOnly: 'ローカルのみ',
+ localOnlySetting: 'ローカル専用設定',
+ ttlBadge: 'TTL {{ttl}}',
+ sessionAffinityDescription:
+ '可能な場合は 1 つの会話を同じアカウントに固定します。CLIProxy はクライアントが明示的に送るセッション ID やスレッド ID を優先し、それがない場合はリクエストのメタデータや冒頭プロンプト履歴から安定キーを推定します。',
+ sessionRecognitionTitle: 'セッション認識',
+ sessionRecognitionDescription:
+ 'CCS はすべてのバックエンドで同一の優先順位を保証しません。一般に上流バックエンドは明示的なセッション / スレッド ID を優先し、その後にメタデータ、最後に冒頭プロンプト履歴のハッシュへフォールバックします。',
},
settingsDialog: {
editProfile: 'プロファイルを編集: {{name}}',
@@ -9990,6 +10082,7 @@ const resources = {
accountsUpdated: 'アカウントを更新しました',
noProfilesToSync: '同期するプロファイルがありません',
syncFailed: '同期に失敗しました: {{error}}',
+ sessionAffinityUpdated: 'セッション固定を{{state}}にしました。',
providerAuthSuccess: '{{provider}} の認証に成功しました',
providerDeviceCodeInCallback:
'コールバックモードでプロバイダーがデバイスコードフローを返しました',
diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts
index 5398645e..ab2ed45b 100644
--- a/ui/src/lib/model-catalogs.ts
+++ b/ui/src/lib/model-catalogs.ts
@@ -259,6 +259,19 @@ export const MODEL_CATALOGS: Record = {
displayName: 'Codex',
defaultModel: 'gpt-5-codex',
models: [
+ {
+ id: 'gpt-5.5',
+ name: 'GPT-5.5',
+ tier: 'paid',
+ description: 'Newest Codex-only GPT-5 family model',
+ codexMaxEffort: 'xhigh',
+ presetMapping: {
+ default: 'gpt-5.5',
+ opus: 'gpt-5.5',
+ sonnet: 'gpt-5.5',
+ haiku: 'gpt-5-codex-mini',
+ },
+ },
{
id: 'gpt-5-codex',
name: 'GPT-5 Codex',
diff --git a/ui/src/lib/sensitive-label.ts b/ui/src/lib/sensitive-label.ts
new file mode 100644
index 00000000..cbd76cae
--- /dev/null
+++ b/ui/src/lib/sensitive-label.ts
@@ -0,0 +1,35 @@
+/**
+ * sensitive-label - Heuristic for detecting field labels that hold credentials
+ * or other secret-like values (per design-system.md §5g).
+ *
+ * Goal: catch the names secret-rotation tooling, OAuth flows, and cloud SDKs
+ * conventionally use. Anything matched here renders with the §5g treatment
+ * (lock + sensitive pill + masked input + reveal toggle) without the caller
+ * needing to set `sensitive` manually.
+ *
+ * The regex is intentionally permissive — false positives (e.g. a "PASSWORD
+ * STRENGTH" UI label that's not actually a credential) are recoverable
+ * (caller can pass `sensitive={false}` explicitly), whereas false negatives
+ * (a real credential rendered in plaintext) are a real leakage risk.
+ */
+
+const SENSITIVE_LABEL_PATTERN =
+ /(AUTH|ACCESS|BEARER|REFRESH|ID)[_-]?TOKEN|API[_-]?(KEY|TOKEN|SECRET)|CLIENT[_-]?(ID|SECRET)|(AWS|GCP|AZURE|GITHUB|GITLAB|OPENAI|ANTHROPIC|GEMINI)[_-]?(ACCESS[_-]?KEY|SECRET[_-]?(KEY|ACCESS[_-]?KEY)|TOKEN|PAT|API[_-]?KEY)|PRIVATE[_-]?KEY|SECRET[_-]?(KEY|ACCESS[_-]?KEY)?|PASSWORD|PASSPHRASE|SSH[_-]?KEY|JWT|OAUTH|CREDENTIAL|\bPAT\b|\bSECRET\b|\bSECRETS\b|SERVICE[_-]?ACCOUNT|WEBHOOK[_-]?SECRET|HMAC[_-]?KEY|SIGNING[_-]?(KEY|SECRET)/i;
+
+/**
+ * Returns true if a field label looks like it holds a credential or secret.
+ * Match is case-insensitive and tolerates `_` / `-` separators.
+ *
+ * Matches include: AUTH_TOKEN, ACCESS_TOKEN, REFRESH_TOKEN, BEARER_TOKEN,
+ * API_KEY, API_TOKEN, API_SECRET, CLIENT_ID, CLIENT_SECRET, AWS_ACCESS_KEY_ID,
+ * AWS_SECRET_ACCESS_KEY, GCP_SERVICE_ACCOUNT_KEY, AZURE_CLIENT_SECRET,
+ * GITHUB_TOKEN, GITLAB_TOKEN, OPENAI_API_KEY, ANTHROPIC_AUTH_TOKEN,
+ * PRIVATE_KEY, SSH_KEY, JWT, OAUTH, CREDENTIAL, PAT, PASSWORD, PASSPHRASE,
+ * WEBHOOK_SECRET, HMAC_KEY, SIGNING_KEY.
+ *
+ * Does NOT match: USERNAME, EMAIL, USER_ID (identifier, not credential),
+ * SESSION_ID (not a long-lived credential), or unrelated field names.
+ */
+export function isSensitiveLabel(label: string): boolean {
+ return SENSITIVE_LABEL_PATTERN.test(label);
+}
diff --git a/ui/src/lib/support-updates-catalog.ts b/ui/src/lib/support-updates-catalog.ts
index e52be324..6b194a6d 100644
--- a/ui/src/lib/support-updates-catalog.ts
+++ b/ui/src/lib/support-updates-catalog.ts
@@ -70,9 +70,9 @@ const BASE_SUPPORT_NOTICES: SupportNotice[] = [
entryIds: ['codex-target', 'codex-cliproxy'],
highlights: [
'Use ccs-codex or ccsx for native Codex runs.',
- 'Use ccsxp for the built-in CCS Codex provider shortcut on native Codex.',
- 'Built-in Codex and Codex bridge profiles can run on native Codex with --target codex.',
- 'Saved default targets for API profiles and variants can now be claude, droid, or codex.',
+ 'Use ccsxp for native Codex with the cliproxy provider shortcut.',
+ 'Use ccs codex --target codex when you want the explicit CCS-managed Codex bridge.',
+ 'Codex remains runtime-only for saved defaults; use runtime aliases or --target codex at launch time.',
],
actions: [
{
@@ -84,15 +84,15 @@ const BASE_SUPPORT_NOTICES: SupportNotice[] = [
},
{
id: 'copy-codex-provider-command',
- label: 'Run built-in Codex on Codex',
- description: 'Use the built-in Codex provider shortcut on native Codex.',
+ label: 'Run native Codex with cliproxy',
+ description: 'Use the native Codex cliproxy shortcut through CCS.',
type: 'command',
command: 'ccsxp "your prompt"',
},
{
id: 'copy-codex-provider-command-explicit',
- label: 'Run built-in Codex on Codex (explicit)',
- description: 'Use the explicit built-in Codex provider route on native Codex.',
+ label: 'Run the managed Codex bridge explicitly',
+ description: 'Use the explicit CCS-managed Codex bridge on native Codex.',
type: 'command',
command: 'ccs codex --target codex "your prompt"',
},
@@ -258,7 +258,7 @@ const BASE_CLI_SUPPORT_ENTRIES: CliSupportEntry[] = [
routes: [{ label: 'Codex CLI', path: '/codex' }],
commands: ['ccs-codex', 'ccsx', 'ccs codex --target codex', 'ccs codex-api --target codex'],
notes:
- 'Saved default targets for API profiles and CLIProxy variants can now be claude, droid, or codex.',
+ 'Codex is still runtime-only for saved defaults; use --target codex or the runtime aliases at launch time.',
},
{
id: 'codex-cliproxy',
@@ -283,7 +283,7 @@ const BASE_CLI_SUPPORT_ENTRIES: CliSupportEntry[] = [
'ccs api create codex-api --cliproxy-provider codex',
],
notes:
- 'Use ccsxp when you want the built-in Codex provider on native Codex without retyping --target codex.',
+ 'Use ccsxp when you want native Codex with the cliproxy shortcut; use ccs codex --target codex for the explicit managed bridge.',
},
{
id: 'gemini-cliproxy',
diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts
index e50d0c5b..00a936f7 100644
--- a/ui/src/lib/utils.ts
+++ b/ui/src/lib/utils.ts
@@ -332,12 +332,33 @@ export type CodexWindowKind =
| 'code-review-5h'
| 'code-review-weekly'
| 'code-review'
+ | 'additional-5h'
+ | 'additional-weekly'
| 'unknown';
/**
- * Map raw Codex API window labels into semantic buckets.
+ * Map a Codex window into a semantic bucket. Prefers explicit category metadata
+ * (post-2026-04 windows) and falls back to label sniffing for legacy cached data.
*/
-export function getCodexWindowKind(label: string): CodexWindowKind {
+export function getCodexWindowKind(labelOrWindow: string | CodexWindowSummary): CodexWindowKind {
+ if (typeof labelOrWindow === 'object' && labelOrWindow !== null) {
+ const w = labelOrWindow;
+ if (w.category === 'additional' && w.cadence) {
+ return w.cadence === 'weekly' ? 'additional-weekly' : 'additional-5h';
+ }
+ if (w.category === 'code-review' && w.cadence) {
+ return w.cadence === 'weekly' ? 'code-review-weekly' : 'code-review-5h';
+ }
+ if (w.category === 'usage' && w.cadence) {
+ return w.cadence === 'weekly' ? 'usage-weekly' : 'usage-5h';
+ }
+ // Missing or incomplete category metadata -> fall through to label sniffing.
+ return getCodexWindowKindFromLabel(w.label);
+ }
+ return getCodexWindowKindFromLabel(labelOrWindow);
+}
+
+function getCodexWindowKindFromLabel(label: string): CodexWindowKind {
const lower = (label || '').toLowerCase();
const isCodeReview = lower.includes('code review') || lower.includes('code_review');
const isPrimary = lower.includes('primary');
@@ -354,7 +375,29 @@ export function getCodexWindowKind(label: string): CodexWindowKind {
return 'unknown';
}
-type CodexWindowSummary = Pick;
+/**
+ * Strip a leading "GPT-X.Y-Codex-" prefix from a feature label and turn the
+ * remainder into a "Codex " display name. Other labels pass through unchanged.
+ *
+ * Examples:
+ * "GPT-5.3-Codex-Spark" -> "Codex Spark"
+ * "OmniReview" -> "OmniReview"
+ * "" -> ""
+ */
+export function prettifyCodexFeatureLabel(featureLabel: string): string {
+ const trimmed = (featureLabel || '').trim();
+ if (!trimmed) return '';
+ const stripped = trimmed.replace(/^GPT-[\d.]+-Codex-/i, '');
+ if (stripped !== trimmed && stripped.length > 0) {
+ return `Codex ${stripped}`;
+ }
+ return trimmed;
+}
+
+type CodexWindowSummary = Pick<
+ CodexQuotaWindow,
+ 'label' | 'resetAfterSeconds' | 'category' | 'cadence' | 'featureLabel'
+>;
/**
* Infer code-review window cadence by comparing against usage windows.
@@ -364,7 +407,7 @@ function inferCodeReviewCadence(
window: CodexWindowSummary,
allWindows: CodexWindowSummary[]
): '5h' | 'weekly' | null {
- const kind = getCodexWindowKind(window.label);
+ const kind = getCodexWindowKind(window);
if (kind === 'code-review-weekly') return 'weekly';
const reset = window.resetAfterSeconds;
@@ -372,14 +415,14 @@ function inferCodeReviewCadence(
const usage5h = allWindows.find(
(w) =>
- getCodexWindowKind(w.label) === 'usage-5h' &&
+ getCodexWindowKind(w) === 'usage-5h' &&
typeof w.resetAfterSeconds === 'number' &&
isFinite(w.resetAfterSeconds) &&
w.resetAfterSeconds > 0
);
const usageWeekly = allWindows.find(
(w) =>
- getCodexWindowKind(w.label) === 'usage-weekly' &&
+ getCodexWindowKind(w) === 'usage-weekly' &&
typeof w.resetAfterSeconds === 'number' &&
isFinite(w.resetAfterSeconds) &&
w.resetAfterSeconds > 0
@@ -400,10 +443,16 @@ export function getCodexWindowDisplayLabel(
const currentWindow: CodexWindowSummary =
typeof labelOrWindow === 'string'
? { label, resetAfterSeconds: null }
- : { label, resetAfterSeconds: labelOrWindow.resetAfterSeconds };
+ : {
+ label,
+ resetAfterSeconds: labelOrWindow.resetAfterSeconds,
+ category: labelOrWindow.category,
+ cadence: labelOrWindow.cadence,
+ featureLabel: labelOrWindow.featureLabel,
+ };
const context = allWindows.length > 0 ? allWindows : [currentWindow];
- switch (getCodexWindowKind(label)) {
+ switch (getCodexWindowKind(currentWindow)) {
case 'usage-5h':
return i18n.t('quotaTooltip.fiveHourLimit');
case 'usage-weekly':
@@ -416,6 +465,15 @@ export function getCodexWindowDisplayLabel(
if (inferred === 'weekly') return i18n.t('utils.codeReviewWeekly');
return i18n.t('utils.codeReview');
}
+ case 'additional-5h':
+ case 'additional-weekly': {
+ const pretty = prettifyCodexFeatureLabel(currentWindow.featureLabel ?? '');
+ const name = pretty || label;
+ const kind = getCodexWindowKind(currentWindow);
+ if (kind === 'additional-5h') return i18n.t('utils.codexAdditional5h', { name });
+ if (kind === 'additional-weekly') return i18n.t('utils.codexAdditionalWeekly', { name });
+ return i18n.t('utils.codexAdditional', { name });
+ }
case 'unknown':
return label;
}
@@ -425,11 +483,18 @@ export interface CodexQuotaBreakdown {
fiveHourWindow: CodexQuotaWindow | null;
weeklyWindow: CodexQuotaWindow | null;
codeReviewWindows: CodexQuotaWindow[];
+ /** Additional rate-limit windows (e.g. GPT-5.3 Codex Spark). Excluded from core 5h/weekly summary. */
+ additionalWindows: CodexQuotaWindow[];
unknownWindows: CodexQuotaWindow[];
}
/**
- * Break down Codex windows into core usage windows (5h + weekly) and auxiliary windows.
+ * Break down Codex windows into core usage windows (5h + weekly), code review,
+ * additional (e.g. Spark), and unknown buckets.
+ *
+ * Prefers explicit category metadata when present so 'additional' windows
+ * (e.g. GPT-5.3 Codex Spark) do not displace core usage windows in the summary.
+ * Falls back to label sniffing for legacy cached data without metadata.
*/
export function getCodexQuotaBreakdown(windows: CodexQuotaWindow[]): CodexQuotaBreakdown {
if (!windows || windows.length === 0) {
@@ -437,6 +502,7 @@ export function getCodexQuotaBreakdown(windows: CodexQuotaWindow[]): CodexQuotaB
fiveHourWindow: null,
weeklyWindow: null,
codeReviewWindows: [],
+ additionalWindows: [],
unknownWindows: [],
};
}
@@ -444,34 +510,64 @@ export function getCodexQuotaBreakdown(windows: CodexQuotaWindow[]): CodexQuotaB
let fiveHourWindow: CodexQuotaWindow | null = null;
let weeklyWindow: CodexQuotaWindow | null = null;
const codeReviewWindows: CodexQuotaWindow[] = [];
+ const additionalWindows: CodexQuotaWindow[] = [];
const unknownWindows: CodexQuotaWindow[] = [];
+ // Eligible windows for the 5h/weekly fallback (must NOT include code-review or additional).
const nonCodeReviewWindows: CodexQuotaWindow[] = [];
- for (const window of windows) {
- const kind = getCodexWindowKind(window.label);
+ const hasCategoryMetadata = windows.some((w) => Boolean(w.category));
- switch (kind) {
- case 'usage-5h':
- if (!fiveHourWindow) fiveHourWindow = window;
+ if (hasCategoryMetadata) {
+ for (const window of windows) {
+ if (window.category === 'usage') {
+ if (window.cadence === '5h' && !fiveHourWindow) fiveHourWindow = window;
+ else if (window.cadence === 'weekly' && !weeklyWindow) weeklyWindow = window;
nonCodeReviewWindows.push(window);
- break;
- case 'usage-weekly':
- if (!weeklyWindow) weeklyWindow = window;
- nonCodeReviewWindows.push(window);
- break;
- case 'code-review-5h':
- case 'code-review-weekly':
- case 'code-review':
+ } else if (window.category === 'code-review') {
codeReviewWindows.push(window);
- break;
- case 'unknown':
+ } else if (window.category === 'additional') {
+ additionalWindows.push(window);
+ } else {
+ // Window has no category but the batch carries metadata for others -> treat as unknown.
unknownWindows.push(window);
nonCodeReviewWindows.push(window);
- break;
+ }
+ }
+ } else {
+ // Legacy path: classify via label sniffing for cached windows without metadata.
+ for (const window of windows) {
+ const kind = getCodexWindowKind(window.label);
+
+ switch (kind) {
+ case 'usage-5h':
+ if (!fiveHourWindow) fiveHourWindow = window;
+ nonCodeReviewWindows.push(window);
+ break;
+ case 'usage-weekly':
+ if (!weeklyWindow) weeklyWindow = window;
+ nonCodeReviewWindows.push(window);
+ break;
+ case 'code-review-5h':
+ case 'code-review-weekly':
+ case 'code-review':
+ codeReviewWindows.push(window);
+ break;
+ case 'additional-5h':
+ case 'additional-weekly':
+ // Unreachable from label-only kind, but kept for exhaustiveness.
+ additionalWindows.push(window);
+ break;
+ case 'unknown':
+ unknownWindows.push(window);
+ nonCodeReviewWindows.push(window);
+ break;
+ }
}
}
// Fallback for API label changes: infer 5h/weekly from reset horizon when explicit labels are absent.
+ // 'additional' windows are intentionally excluded from this pool to avoid leaking Spark windows
+ // into the core usage badges on a fresh Pro account.
if ((!fiveHourWindow || !weeklyWindow) && nonCodeReviewWindows.length > 0) {
const withReset = nonCodeReviewWindows
.filter((w) => typeof w.resetAfterSeconds === 'number' && w.resetAfterSeconds >= 0)
@@ -493,6 +589,7 @@ export function getCodexQuotaBreakdown(windows: CodexQuotaWindow[]): CodexQuotaB
fiveHourWindow,
weeklyWindow,
codeReviewWindows,
+ additionalWindows,
unknownWindows,
};
}
@@ -597,11 +694,10 @@ export function getGeminiResetTime(buckets: GeminiCliBucket[]): string | null {
export function getMinGhcpQuota(snapshots: GhcpQuotaResult['snapshots']): number | null {
if (!snapshots) return null;
- const percentages = [
- snapshots.premiumInteractions.percentRemaining,
- snapshots.chat.percentRemaining,
- snapshots.completions.percentRemaining,
- ].filter((p) => typeof p === 'number' && isFinite(p));
+ const percentages = [snapshots.premiumInteractions, snapshots.chat, snapshots.completions]
+ .filter((snapshot) => snapshot.reported !== false)
+ .map((snapshot) => (snapshot.unlimited ? 100 : snapshot.percentRemaining))
+ .filter((p) => typeof p === 'number' && isFinite(p));
if (percentages.length === 0) return null;
return Math.min(...percentages);
diff --git a/ui/src/pages/_styleguide.tsx b/ui/src/pages/_styleguide.tsx
new file mode 100644
index 00000000..cc25bb8d
--- /dev/null
+++ b/ui/src/pages/_styleguide.tsx
@@ -0,0 +1,888 @@
+/**
+ * /_styleguide — DEV-ONLY route showcasing the CCS design system.
+ *
+ * Renders every primitive in isolation plus composed Config + Monitor archetype demos.
+ * Gated by import.meta.env.DEV in App.tsx — never exposed in production builds.
+ *
+ * All demo data is anonymized (Provider A/B/C, fake metrics) so screenshots are
+ * safe to publish in PRs without enabling Privacy mode.
+ */
+import { useState } from 'react';
+import {
+ Activity,
+ Bot,
+ Cloud,
+ Cpu,
+ Key,
+ Plus,
+ RefreshCcw,
+ RefreshCw,
+ ShieldCheck,
+ Sparkles,
+ Users,
+ Zap,
+} from 'lucide-react';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { cn } from '@/lib/utils';
+import { PageShell, PageHeader, EmptyState, ErrorState } from '@/components/page-shell';
+import {
+ ConfigLayout,
+ ListPane,
+ SectionRail,
+ FormPane,
+ FormSection,
+ JsonPane,
+ type ListPaneItem,
+ type SectionRailItem,
+} from '@/components/config-layout';
+import {
+ MonitorLayout,
+ MonitorGrid,
+ MonitorCard,
+ KpiRow,
+ KpiCard,
+} from '@/components/monitor-layout';
+import { Field } from '@/components/forms/field';
+
+const DEMO_PROVIDERS: ListPaneItem[] = [
+ { id: 'provider-a', label: 'Provider A', badge: '14', icon: },
+ { id: 'provider-b', label: 'Provider B', badge: '3', icon: },
+ { id: 'provider-c', label: 'Provider C', badge: '70', icon: },
+ { id: 'provider-d', label: 'Provider D', badge: '1', icon: },
+];
+
+const DEMO_SECTIONS: SectionRailItem[] = [
+ { id: 'general', label: 'General' },
+ { id: 'auth', label: 'Authentication' },
+ { id: 'routing', label: 'Routing' },
+ { id: 'models', label: 'Models' },
+ { id: 'tools', label: 'Tools & MCP' },
+ { id: 'advanced', label: 'Advanced' },
+];
+
+const DEMO_CONFIG = {
+ env: {
+ ANTHROPIC_BASE_URL: 'https://api.example.local/anthropic',
+ ANTHROPIC_AUTH_TOKEN: '••••••••••••••••••••••••',
+ API_TIMEOUT_MS: '3000000',
+ ANTHROPIC_MODEL: 'demo-1.0',
+ ANTHROPIC_DEFAULT_OPUS_MODEL: 'demo-1.0',
+ ANTHROPIC_DEFAULT_SONNET_MODEL: 'demo-1',
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: 'demo-0.5-air',
+ DISABLE_TELEMETRY: '1',
+ ANTHROPIC_TEMPERATURE: '0.2',
+ ANTHROPIC_MAX_TOKENS: '65536',
+ },
+ routing: {
+ strategy: 'weighted-round-robin',
+ failover: ['provider-b', 'provider-c'],
+ },
+ permissions: {
+ defaultMode: 'bypassPermissions',
+ },
+};
+
+export function StyleguidePage() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Add provider
+
+ }
+ />
+
+ Retry
+
+ }
+ />
+
+
+
+ );
+}
+
+// -----------------------------------------------------------------------------
+// Section helpers
+// -----------------------------------------------------------------------------
+
+function Intro() {
+ return (
+
+
+ DEV ONLY · /_styleguide
+
+ CCS Dashboard Design System
+
+ Three identity-strip patterns — HeroBar (one-row dense, see /{' '}
+ home page), rail-anchored (no top chrome, see /cliproxy), and{' '}
+ PageHeader (Monitor-only) — and two body archetypes:{' '}
+ Config (rail + form + optional JSON) and Monitor (KPI row
+ + grid).
+
+
+
§0 Layout invariants — never violate
+
+ -
+ Config pages are a strict two-column shell: largest pane on the left
+ (rail / list owns identity), main content on the right. Both columns flush against the
+ global topbar, filling the viewport.
+
+ -
+ No second horizontal strip below the global topbar. No{' '}
+
PageHeader, breadcrumb row, description band, or KPI ribbon above a{' '}
+ ConfigLayout.
+
+ -
+ Form and JSON panes are siblings sharing one top edge. Tab bars live
+ inside the form pane's scroll area, never as a sibling row that offsets the JSON
+ pane.
+
+ -
+
pages/cliproxy.tsx is the canonical Config reference. When a Config page
+ disagrees with it on layout shape, the page is wrong.
+
+ -
+ The left rail is content-fit within a unified envelope (
+
w-fit min-w-[240px] max-w-[360px]). Same primitives, same treatment
+ everywhere; width adapts to header content so titles never wrap. Pages MUST NOT override
+ this envelope.
+
+ -
+ The form ↔ JSON split is user-resizable via a draggable divider; the
+ rail itself is NOT user-resizable. Min widths: form ≥ 360px, json ≥ 320px. Ratio
+ persists in
localStorage per-page.
+
+
+
+
+ Full spec: ui/docs/design-system.md · revision history:{' '}
+ ui/docs/design-decisions.md (current: v1.8, 2026-04-26). Color rules: §5.
+
+
+ );
+}
+
+function PrimitiveSection({
+ title,
+ anchor,
+ children,
+}: {
+ title: string;
+ anchor: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
+
+// -----------------------------------------------------------------------------
+// HeroBar demo (canonical: pages/home.tsx)
+//
+// One row packs logo + title + version + ≤4 inline stats. Optional dotted
+// background pattern. Stats are clickable when they double as nav entry points.
+// Use it for dashboard pages with a clear product identity and ≤4 hero numbers.
+// -----------------------------------------------------------------------------
+
+function DemoInlineStat({
+ title,
+ value,
+ icon: Icon,
+ variant = 'default',
+}: {
+ title: string;
+ value: string | number;
+ icon: typeof Activity;
+ variant?: 'default' | 'accent' | 'success' | 'warning';
+}) {
+ const styles = {
+ default: 'bg-muted text-muted-foreground',
+ accent: 'bg-accent/15 text-accent',
+ success: 'bg-emerald-600/15 text-emerald-700 dark:text-emerald-400',
+ warning: 'bg-amber-500/15 text-amber-700 dark:text-amber-400',
+ }[variant];
+ const valueColor = {
+ default: 'text-foreground',
+ accent: 'text-accent',
+ success: 'text-emerald-700 dark:text-emerald-400',
+ warning: 'text-amber-700 dark:text-amber-400',
+ }[variant];
+
+ return (
+
+
+
+
+
+
+ );
+}
+
+function DemoHeroBar() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
Demo Dashboard
+
+ v1.0.0
+
+
+
+ Single-row dense hero — logo + title + version + inline stats
+
+
+
+
+
+
+
+
+
+
+
+
+ Body content renders below — no separate KpiRow needed since stats are absorbed into the
+ hero strip.
+
+
+ );
+}
+
+// -----------------------------------------------------------------------------
+// Rail-anchored identity demo (canonical: pages/cliproxy.tsx)
+//
+// Page identity (brand + page-level CTA + status) lives inside the left rail of
+// a Config 3-pane layout. Zero top chrome. The body archetype gets the full
+// vertical viewport. Use for multi-entity Config pages where the rail naturally
+// carries the page name.
+// -----------------------------------------------------------------------------
+
+function DemoRailAnchored() {
+ const [selectedId, setSelectedId] = useState('provider-a');
+
+ const railHeader = (
+
+ {/* Brand strip — replaces the global PageHeader */}
+
+
+
+
+
Demo Brand
+
+
+
+
+
+
Page subtitle / description
+
+ Quick Setup
+
+
+
+ {/* Provider list */}
+
+
+
+ Providers
+
+
+ {DEMO_PROVIDERS.map((p) => (
+ setSelectedId(p.id)}
+ className={cn(
+ 'flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors',
+ selectedId === p.id ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50'
+ )}
+ >
+ {p.icon}
+ {p.label}
+ {p.badge}
+
+ ))}
+
+
+
+
+ {/* Footer status */}
+
+
+ 4 providers
+
+ 1 connected
+
+
+
+
+ );
+
+ return (
+
+ {/* NOTE: NO PageHeader at the top. Identity is inside the rail. */}
+
+
+
Provider A
+
+ Last modified: 4/12/2026 · 14 accounts · synced 2m ago
+
+
+ connected
+
+ }
+ footer={
+ <>
+ Save
+
+ Test connection
+
+ >
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ json={}
+ />
+
+ );
+}
+
+// -----------------------------------------------------------------------------
+// PageHeader demo — MONITOR-ONLY use (§0b / §1c).
+//
+// PageHeader stacks a second horizontal strip below the global topbar, which
+// costs vertical real estate. That cost is acceptable ONLY when the body is a
+// Monitor archetype (KPI row + grid) with NO left rail. NEVER place PageHeader
+// above a ConfigLayout — the rail owns identity in Config pages.
+// -----------------------------------------------------------------------------
+
+function DemoPageHeader() {
+ const tiles: Array<{
+ label: string;
+ value: string;
+ hint: string;
+ tone: 'positive' | 'warning' | 'default';
+ spark: number[];
+ }> = [
+ {
+ label: 'Active',
+ value: '87',
+ hint: '▲ 3 vs yesterday',
+ tone: 'positive',
+ spark: [62, 70, 68, 74, 80, 82, 85, 87],
+ },
+ {
+ label: 'Requests/24h',
+ value: '12,481',
+ hint: '▲ 6.4%',
+ tone: 'positive',
+ spark: [40, 55, 48, 60, 70, 75, 82, 90],
+ },
+ {
+ label: 'Errors',
+ value: '12',
+ hint: '3 quota · 9 transient',
+ tone: 'warning',
+ spark: [4, 6, 3, 8, 10, 7, 9, 12],
+ },
+ {
+ label: 'Uptime',
+ value: '99.98%',
+ hint: '30-day window',
+ tone: 'default',
+ spark: [98, 99, 99, 99, 100, 100, 99, 100],
+ },
+ ];
+
+ return (
+
+
+ Allowed only above Monitor body without a rail. Never stack above{' '}
+ ConfigLayout — see §4a anti-pattern.
+
+
+ All systems nominal}
+ actions={
+ <>
+
+ Refresh
+
+
+ New source
+
+ >
+ }
+ />
+
+ {tiles.map((t) => {
+ const max = Math.max(...t.spark);
+ const toneStroke =
+ t.tone === 'positive'
+ ? 'stroke-emerald-500'
+ : t.tone === 'warning'
+ ? 'stroke-amber-500'
+ : 'stroke-foreground/60';
+ return (
+
+
+ {t.label}
+
+
{t.value}
+
{t.hint}
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+// -----------------------------------------------------------------------------
+// Config (multi-entity) demo — rail-anchored, NO top chrome (§0b / §1b).
+//
+// Identity (brand + page CTA) lives inside ListPane.header. The two columns
+// share one top edge flush against the global topbar; the body fills the
+// viewport. Canonical reference: pages/cliproxy.tsx.
+// -----------------------------------------------------------------------------
+
+function DemoConfigMulti() {
+ const [selectedId, setSelectedId] = useState('provider-a');
+ const [search, setSearch] = useState('');
+
+ const filtered = DEMO_PROVIDERS.filter((p) =>
+ String(p.label).toLowerCase().includes(search.toLowerCase())
+ );
+
+ const railHeader = (
+
+
+
+
+
CLIProxy
+
+
+ 4 providers
+
+
+
+ New provider
+
+
+ );
+
+ return (
+
+ {/* NO PageHeader above ConfigLayout — see §4a anti-pattern. */}
+
+ {filtered.length} shown
+
+ 1 connected
+
+
+ }
+ />
+ }
+ form={
+
+
+
Provider A
+
14 accounts · synced 2m ago
+
+ connected
+
+ }
+ footer={
+ <>
+ Save
+
+ Test connection
+
+ >
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ json={}
+ />
+
+ );
+}
+
+// -----------------------------------------------------------------------------
+// Config (single-entity) demo — rail-anchored, NO top chrome (§0b / §1b).
+//
+// Identity (brand + version + primary CTA) lives inside SectionRail.header.
+// Both columns flush against the global topbar; the form scrolls inside its
+// own pane while the rail stays sticky. No PageHeader above the layout.
+// -----------------------------------------------------------------------------
+
+function DemoConfigSingle() {
+ const railHeader = (
+
+
+
+
+
Cursor
+
+
+ v0.42
+
+
+
+ Open editor
+
+
+ );
+
+ return (
+
+ {/* NO PageHeader above ConfigLayout — see §4a anti-pattern. */}
+
}
+ form={
+
+
+
Cursor IDE
+
+ Last modified: 4/12/2026 · workspace synced
+
+
+ connected
+
+ }
+ footer={
+ <>
+ Save configuration
+
+ Open editor
+
+ >
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ json={
+
+ }
+ />
+
+ );
+}
+
+// -----------------------------------------------------------------------------
+// Monitor demo
+// -----------------------------------------------------------------------------
+
+function DemoMonitor() {
+ return (
+
+
All systems nominal}
+ actions={
+
+ Refresh
+
+ }
+ />
+
+ }
+ />
+ }
+ />
+
+ }
+ />
+
+ }
+ >
+
+
+
+ {[42, 58, 31, 81, 12, 96, 24, 19].map((pct, i) => (
+
+
Account {i + 1}
+
tier · {pct}%
+
+
80
+ ? 'h-full bg-destructive'
+ : pct > 60
+ ? 'h-full bg-amber-500'
+ : 'h-full bg-emerald-500'
+ }
+ style={{ width: `${pct}%` }}
+ />
+
+
+ ))}
+
+
+
+
+
+ {[
+ { name: 'Provider A', share: '62%' },
+ { name: 'Provider C', share: '24%' },
+ { name: 'Provider B', share: '9%' },
+ { name: 'Provider D', share: '5%' },
+ ].map((row) => (
+ -
+ {row.name}
+ {row.share}
+
+ ))}
+
+
+
+
+
+ {[14, 22, 30, 38, 28, 35, 42, 55, 48, 60, 70, 64, 78, 82, 90, 88, 95].map((h, i) => (
+
+ ))}
+
+
+
+
+
+ {`[OK] cliproxy :8317 uptime 14d 02h
+[OK] dashboard :3000 uptime 14d 02h
+[OK] qdrant :6333 uptime 21d 11h
+[OK] postgres :5432 uptime 47d 03h
+[!] ollama-gpu gpu 78% vram 9.4/12GB
+[OK] runner self-hosted online`}
+
+
+
+
+
+ );
+}
+
+// -----------------------------------------------------------------------------
+// tiny field helper (extracted to shared component)
+// -----------------------------------------------------------------------------
diff --git a/ui/src/pages/analytics/hooks.ts b/ui/src/pages/analytics/hooks.ts
index f4fc73ef..359e769b 100644
--- a/ui/src/pages/analytics/hooks.ts
+++ b/ui/src/pages/analytics/hooks.ts
@@ -18,6 +18,8 @@ import {
type ModelUsage,
} from '@/hooks/use-usage';
+const RECENT_SESSION_SAMPLE_LIMIT = 50;
+
export function useAnalyticsPage() {
// Default to last 30 days
const [dateRange, setDateRange] = useState({
@@ -55,7 +57,10 @@ export function useAnalyticsPage() {
const { data: trends, isLoading: isTrendsLoading } = useUsageTrends(apiOptions);
const { data: hourlyData, isLoading: isHourlyLoading } = useHourlyUsage(apiOptions);
const { data: models, isLoading: isModelsLoading } = useModelUsage(apiOptions);
- const { data: sessions, isLoading: isSessionsLoading } = useSessions({ ...apiOptions, limit: 3 });
+ const { data: sessions, isLoading: isSessionsLoading } = useSessions({
+ ...apiOptions,
+ limit: RECENT_SESSION_SAMPLE_LIMIT,
+ });
const { data: status } = useUsageStatus();
// Handle "24H" preset click
diff --git a/ui/src/pages/health.tsx b/ui/src/pages/health.tsx
index 5239fd5c..4a1dbf3f 100644
--- a/ui/src/pages/health.tsx
+++ b/ui/src/pages/health.tsx
@@ -1,78 +1,34 @@
-import { Button } from '@/components/ui/button';
-import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
-import { RefreshCw, Terminal, Copy, Cpu } from 'lucide-react';
-import { HealthGauge } from '@/components/health/health-gauge';
-import { HealthStatsBar } from '@/components/health/health-stats-bar';
-import { HealthGroupSection } from '@/components/health/health-group-section';
-import { useHealth, type HealthGroup } from '@/hooks/use-health';
+import { ShieldCheck } from 'lucide-react';
+import { HealthStatusRibbon, HealthPriorityList, HealthAuditSection } from '@/components/health';
+import { useHealth } from '@/hooks/use-health';
import { cn } from '@/lib/utils';
-import { toast } from 'sonner';
-import { useEffect, useState } from 'react';
+import { motion } from 'framer-motion';
import { useTranslation } from 'react-i18next';
-function getOverallStatus(summary: { passed: number; warnings: number; errors: number }) {
- if (summary.errors > 0) return 'error';
- if (summary.warnings > 0) return 'warning';
- return 'ok';
-}
-
-function formatRelativeTime(
- timestamp: number,
- t: (key: string, options?: Record) => string
-): string {
- const seconds = Math.floor((Date.now() - timestamp) / 1000);
- if (seconds < 5) return t('health.justNow');
- if (seconds < 60) return t('health.secondsAgo', { count: seconds });
- const minutes = Math.floor(seconds / 60);
- if (minutes < 60) return t('health.minutesAgo', { count: minutes });
- const hours = Math.floor(minutes / 60);
- return t('health.hoursAgo', { count: hours });
-}
-
-function sortGroupsByIssues(groups: HealthGroup[]): HealthGroup[] {
- return [...groups].sort((a, b) => {
- const aErrors = a.checks.filter((c) => c.status === 'error').length;
- const bErrors = b.checks.filter((c) => c.status === 'error').length;
- const aWarnings = a.checks.filter((c) => c.status === 'warning').length;
- const bWarnings = b.checks.filter((c) => c.status === 'warning').length;
- if (aErrors !== bErrors) return bErrors - aErrors;
- return bWarnings - aWarnings;
- });
-}
-
-function TerminalHeader() {
- return (
-
- $
- ccs doctor
-
- );
-}
-
function LoadingSkeleton() {
return (
-
- {/* Hero skeleton */}
-
-
-
-
-
-
-
+
+ {/* Ribbon skeleton */}
+
+
+
+ {/* Priority skeleton */}
+
-
- {/* Stats skeleton */}
-
-
- {/* Groups skeleton */}
-
- {[1, 2, 3, 4].map((i) => (
-
- ))}
+ {/* Audit skeleton */}
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
);
@@ -82,180 +38,98 @@ export function HealthPage() {
const { t } = useTranslation();
const { data, isLoading, refetch, dataUpdatedAt } = useHealth();
- // Use dataUpdatedAt directly instead of storing in state
- const lastRefresh = dataUpdatedAt;
-
- // Update relative time display by forcing re-render every second
- const [tick, setTick] = useState(0);
- useEffect(() => {
- const interval = setInterval(() => setTick((t) => t + 1), 1000);
- return () => clearInterval(interval);
- }, []);
- // Consume tick to prevent unused variable warning
- void tick;
-
- const copyDoctorCommand = () => {
- navigator.clipboard.writeText('ccs doctor');
- toast.success(t('health.copied'));
- };
-
- const handleRefresh = () => {
- refetch();
- toast.info(t('health.refreshing'));
- };
-
if (isLoading && !data) {
return
;
}
- const overallStatus = data ? getOverallStatus(data.summary) : 'ok';
- const sortedGroups = data?.groups ? sortGroupsByIssues(data.groups) : [];
+ const priorityChecks =
+ data?.checks.filter((c) => c.status === 'error' || c.status === 'warning') ?? [];
+ const hasIssues = priorityChecks.length > 0;
return (
-
- {/* Hero Section - Terminal-inspired control center header */}
-
- {/* Subtle scan lines effect */}
+
+ {/* Dynamic Background */}
+
+
+ {/* Grain overlay - Local SVG data URI for reliability */}
+
-
- {/* Grid pattern background */}
-
-
-
- {/* Left: Health Gauge - excludes info from percentage */}
- {data && (
-
-
-
- )}
-
- {/* Center: Title and status */}
-
- {/* Terminal prompt */}
-
-
- {/* Main title */}
-
-
- {t('health.systemHealth')}
-
- {data?.version && (
-
- {t('health.build', { version: data.version })}
-
- )}
-
-
- {/* Status message */}
-
-
- {t('health.lastScan')}
-
- {lastRefresh ? formatRelativeTime(lastRefresh, t) : '--'}
-
- |
- {t('health.autoRefresh')}
- 30s
-
-
-
- {/* Right: Actions */}
-
-
-
- ccs doctor
-
-
-
-
- {t('health.refresh')}
-
- R
-
-
-
-
- {/* Stats Bar */}
- {data && (
-
-
+ {/* Status Ribbon */}
+ {data && (
+
-
- )}
+ )}
- {/* Health Check Groups - Single column layout */}
- {sortedGroups.length > 0 && (
-
- {sortedGroups.map((group, index) => (
- c.status === 'error' || c.status === 'warning')
- }
- />
- ))}
-
- )}
+
+ {/* Priority Issues */}
+ {hasIssues ? (
+
+ ) : (
+
+
+
+
{t('health.allSystemsClear')}
+
+ {t('health.optimalStateDesc')}
+
+
+
+ )}
- {/* Footer metadata */}
-
-
-
- {t('health.version')} {data?.version ?? '--'}
-
-
- {t('health.platform')}{' '}
-
- {typeof navigator !== 'undefined' ? navigator.platform : 'linux'}
-
-
+ {/* Detailed Audit */}
+ {data?.groups && }
-
-
-
{t('health.liveMonitoring')}
+
+ {/* Footer metadata */}
+
+
+
+ Build
+ {data?.version ?? '--'}
+
+
+ Platform
+
+ {typeof navigator !== 'undefined' ? navigator.platform : 'linux'}
+
+
+
+
+
+
{t('health.liveMonitoring')}
+
diff --git a/ui/tests/setup/vitest-setup.ts b/ui/tests/setup/vitest-setup.ts
index 0fa24398..c3815df1 100644
--- a/ui/tests/setup/vitest-setup.ts
+++ b/ui/tests/setup/vitest-setup.ts
@@ -11,6 +11,8 @@ import { afterEach, vi } from 'vitest';
// Cleanup after each test
afterEach(() => {
cleanup();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
});
// Mock matchMedia for components that use media queries
diff --git a/ui/tests/unit/components/analytics/session-stats-card.test.tsx b/ui/tests/unit/components/analytics/session-stats-card.test.tsx
index e09776c1..04e5eae8 100644
--- a/ui/tests/unit/components/analytics/session-stats-card.test.tsx
+++ b/ui/tests/unit/components/analytics/session-stats-card.test.tsx
@@ -1,15 +1,9 @@
-/**
- * Session Stats Card Tests
- * Unit tests for SessionStatsCard component with project name formatting
- */
-
-import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
-import { SessionStatsCard } from '../../../../src/components/analytics/session-stats-card';
-import { AllProviders } from '../../../setup/test-utils';
-import type { PaginatedSessions } from '../../../../src/hooks/use-usage';
+import { SessionStatsCard } from '@/components/analytics/session-stats-card';
+import type { PaginatedSessions, Session } from '@/hooks/use-usage';
+import { AllProviders } from '@tests/setup/test-utils';
-// Mock date-fns to return consistent dates
vi.mock('date-fns', async () => {
const actual = await vi.importActual('date-fns');
return {
@@ -18,288 +12,133 @@ vi.mock('date-fns', async () => {
};
});
+function buildSession(overrides: Partial
= {}): Session {
+ return {
+ sessionId: 'session-1',
+ projectPath: '/Users/kaitran/CloudPersonal/worktrees/ccs-cli/feature-branch',
+ inputTokens: 1_500,
+ outputTokens: 2_500,
+ cost: 0.08,
+ lastActivity: '2026-04-26T14:00:00.000Z',
+ modelsUsed: ['claude-sonnet-4'],
+ ...overrides,
+ };
+}
+
+function buildPaginatedSessions(overrides: Partial = {}): PaginatedSessions {
+ return {
+ sessions: [buildSession()],
+ total: 1,
+ limit: 50,
+ offset: 0,
+ hasMore: false,
+ ...overrides,
+ };
+}
+
describe('SessionStatsCard', () => {
beforeEach(() => {
- // Reset all mocks
vi.clearAllMocks();
});
- describe('Loading and Empty States', () => {
- it('renders loading skeleton when isLoading is true', () => {
- const { container } = render(, {
- wrapper: AllProviders,
- });
-
- // Should have skeleton loading elements
- expect(container.querySelector('[data-slot="skeleton"]')).toBeInTheDocument();
+ it('renders a loading skeleton', () => {
+ const { container } = render(, {
+ wrapper: AllProviders,
});
- it('shows empty state when no data available', () => {
- render(, { wrapper: AllProviders });
-
- expect(screen.getByText('Session Stats')).toBeInTheDocument();
- expect(screen.getByText('No session data available')).toBeInTheDocument();
- });
-
- it('shows empty state when sessions array is empty', () => {
- const emptyData: PaginatedSessions = {
- sessions: [],
- total: 0,
- page: 1,
- pageSize: 10,
- };
-
- render(, { wrapper: AllProviders });
-
- expect(screen.getByText('Session Stats')).toBeInTheDocument();
- expect(screen.getByText('No session data available')).toBeInTheDocument();
- });
+ expect(container.querySelector('[data-slot="skeleton"]')).toBeInTheDocument();
});
- describe('Session Stats Display', () => {
- const createMockSession = (
- projectPath: string,
- inputTokens: number,
- outputTokens: number,
- cost: number
- ) => ({
- sessionId: `session-${Math.random()}`,
- projectPath,
- inputTokens,
- outputTokens,
- cost,
- lastActivity: new Date().toISOString(),
+ it('renders the empty state with the live paginated contract', () => {
+ render(, {
+ wrapper: AllProviders,
});
- const mockData: PaginatedSessions = {
+ expect(screen.getByText('Session Stats')).toBeInTheDocument();
+ expect(screen.getByText('No session data available')).toBeInTheDocument();
+ });
+
+ it('keeps subset session metrics explicitly sample-scoped when pagination truncates the result set', () => {
+ const data = buildPaginatedSessions({
sessions: [
- createMockSession('/home/user/projects/my-app', 1500, 2500, 0.08),
- createMockSession(
- '/home/user/workspaces/repo-name/worktrees/feature-branch',
- 2000,
- 3000,
- 0.12
- ),
- createMockSession('/Users/joe/Developer/share-pi', 1000, 2000, 0.05),
+ buildSession({ sessionId: 'session-1', cost: 0.08 }),
+ buildSession({
+ sessionId: 'session-2',
+ projectPath: '/Users/kaitran/projects/platform/worktrees/kai-fix',
+ inputTokens: 2_000,
+ outputTokens: 3_000,
+ cost: 0.12,
+ target: 'codex',
+ }),
+ buildSession({
+ sessionId: 'session-3',
+ projectPath: '/Users/kaitran/projects/share-pi',
+ inputTokens: 1_000,
+ outputTokens: 2_000,
+ cost: 0.05,
+ }),
],
- total: 3,
- page: 1,
- pageSize: 10,
- };
-
- beforeEach(() => {
- mockData.sessions = [
- createMockSession('/home/user/projects/my-app', 1500, 2500, 0.08),
- createMockSession(
- '/home/user/workspaces/repo-name/worktrees/feature-branch',
- 2000,
- 3000,
- 0.12
- ),
- createMockSession('/Users/joe/Developer/share-pi', 1000, 2000, 0.05),
- ];
+ total: 9,
+ hasMore: true,
});
- it('displays session stats header', () => {
- render(, { wrapper: AllProviders });
+ render(, { wrapper: AllProviders });
- expect(screen.getByText('Session Stats')).toBeInTheDocument();
- });
-
- it('shows total sessions count', () => {
- render(, { wrapper: AllProviders });
-
- expect(screen.getByText('3')).toBeInTheDocument();
- expect(screen.getByText('Total Sessions')).toBeInTheDocument();
- });
-
- it('calculates and displays average cost per session', () => {
- render(, { wrapper: AllProviders });
-
- // Average cost: (0.08 + 0.12 + 0.05) / 3 = 0.0833 → $0.08
- // Use getAllByText since cost may appear multiple times (per session + average)
- const costElements = screen.getAllByText('$0.08');
- expect(costElements.length).toBeGreaterThan(0);
- expect(screen.getByText('Avg Cost/Session')).toBeInTheDocument();
- });
-
- it('shows recent activity section', () => {
- render(, { wrapper: AllProviders });
-
- expect(screen.getByText('Recent Activity')).toBeInTheDocument();
- });
+ expect(screen.getByText('Sampled Sessions')).toBeInTheDocument();
+ expect(screen.getByText('3')).toBeInTheDocument();
+ expect(screen.getByText('9 total')).toBeInTheDocument();
+ expect(screen.getByText('Recent Avg Cost')).toBeInTheDocument();
+ expect(screen.getAllByText('$0.08').length).toBeGreaterThan(0);
+ expect(screen.getByText('codex')).toBeInTheDocument();
});
- describe('Project Name Formatting', () => {
- it('displays correct project name for simple path', () => {
- const mockData: PaginatedSessions = {
- sessions: [
- {
- sessionId: '1',
- projectPath: '/home/user/projects/my-app',
- inputTokens: 1000,
- outputTokens: 2000,
- cost: 0.05,
- lastActivity: new Date().toISOString(),
- },
- ],
- total: 1,
- page: 1,
- pageSize: 10,
- };
-
- render(, { wrapper: AllProviders });
-
- // Should show "my-app" instead of just "app"
- expect(screen.getByTitle('/home/user/projects/my-app')).toHaveTextContent('my-app');
+ it('keeps the overall average label when the full result set is loaded', () => {
+ const data = buildPaginatedSessions({
+ sessions: [
+ buildSession({ sessionId: 'session-1', cost: 0.04 }),
+ buildSession({ sessionId: 'session-2', cost: 0.06 }),
+ ],
+ total: 2,
+ hasMore: false,
});
- it('displays correct project name for worktree path', () => {
- const mockData: PaginatedSessions = {
- sessions: [
- {
- sessionId: '1',
- projectPath:
- '/Users/joe/Developer/ExaDev/Clients/Architect/repositories/architect/worktrees/2026-01-08',
- inputTokens: 1000,
- outputTokens: 2000,
- cost: 0.05,
- lastActivity: new Date().toISOString(),
- },
- ],
- total: 1,
- page: 1,
- pageSize: 10,
- };
+ render(, { wrapper: AllProviders });
- render(, { wrapper: AllProviders });
-
- // Should show "2026-01-08" instead of just "08"
- expect(
- screen.getByTitle(
- '/Users/joe/Developer/ExaDev/Clients/Architect/repositories/architect/worktrees/2026-01-08'
- )
- ).toHaveTextContent('2026-01-08');
- });
-
- it('displays correct project name for shared project', () => {
- const mockData: PaginatedSessions = {
- sessions: [
- {
- sessionId: '1',
- projectPath: '/Users/joe/Developer/share-pi',
- inputTokens: 1000,
- outputTokens: 2000,
- cost: 0.05,
- lastActivity: new Date().toISOString(),
- },
- ],
- total: 1,
- page: 1,
- pageSize: 10,
- };
-
- render(, { wrapper: AllProviders });
-
- // Should show "share-pi" instead of just "pi"
- expect(screen.getByTitle('/Users/joe/Developer/share-pi')).toHaveTextContent('share-pi');
- });
-
- it('handles empty project path gracefully', () => {
- const mockData: PaginatedSessions = {
- sessions: [
- {
- sessionId: '1',
- projectPath: '',
- inputTokens: 1000,
- outputTokens: 2000,
- cost: 0.05,
- lastActivity: new Date().toISOString(),
- },
- ],
- total: 1,
- page: 1,
- pageSize: 10,
- };
-
- render(, { wrapper: AllProviders });
-
- // Should not crash and show empty string
- expect(screen.getByTitle('')).toHaveTextContent('');
- });
-
- it('handles project path with only slashes', () => {
- const mockData: PaginatedSessions = {
- sessions: [
- {
- sessionId: '1',
- projectPath: '///',
- inputTokens: 1000,
- outputTokens: 2000,
- cost: 0.05,
- lastActivity: new Date().toISOString(),
- },
- ],
- total: 1,
- page: 1,
- pageSize: 10,
- };
-
- render(, { wrapper: AllProviders });
-
- // Should handle gracefully and show empty string
- expect(screen.getByTitle('///')).toHaveTextContent('');
- });
+ expect(screen.getByText('Avg Cost/Session')).toBeInTheDocument();
+ expect(screen.queryByText('Recent Avg Cost')).not.toBeInTheDocument();
});
- describe('Token Count Display', () => {
- it('displays token counts in compact format', () => {
- const mockData: PaginatedSessions = {
- sessions: [
- {
- sessionId: '1',
- projectPath: '/project/test',
- inputTokens: 1500000, // 1.5M
- outputTokens: 500000, // 500K
- cost: 0.1,
- lastActivity: new Date().toISOString(),
- },
- ],
- total: 1,
- page: 1,
- pageSize: 10,
- };
-
- render(, { wrapper: AllProviders });
-
- expect(screen.getByText('2.0M toks')).toBeInTheDocument();
+ it('formats project names from worktree paths without regressing the live fixture shape', () => {
+ const data = buildPaginatedSessions({
+ sessions: [
+ buildSession({
+ projectPath:
+ '/Users/kaitran/Developer/ExaDev/Clients/Architect/repositories/architect/worktrees/2026-01-08',
+ }),
+ ],
});
+
+ render(, { wrapper: AllProviders });
+
+ expect(
+ screen.getByTitle(
+ '/Users/kaitran/Developer/ExaDev/Clients/Architect/repositories/architect/worktrees/2026-01-08'
+ )
+ ).toHaveTextContent('2026-01-08');
});
- describe('Privacy Mode', () => {
- it('blurs cost information when privacy mode is enabled', () => {
- // This would require mocking the privacy context
- // For now, just ensure the component renders with privacy mode
- const testData: PaginatedSessions = {
- sessions: [
- {
- sessionId: '1',
- projectPath: '/home/user/project',
- inputTokens: 1000,
- outputTokens: 2000,
- cost: 0.05,
- lastActivity: new Date().toISOString(),
- },
- ],
- total: 1,
- page: 1,
- pageSize: 10,
- };
-
- const { container } = render(, { wrapper: AllProviders });
-
- // Component should render without errors
- expect(container).toBeInTheDocument();
+ it('formats visible token counts from input plus output only', () => {
+ const data = buildPaginatedSessions({
+ sessions: [
+ buildSession({
+ inputTokens: 1_500_000,
+ outputTokens: 500_000,
+ }),
+ ],
});
+
+ render(, { wrapper: AllProviders });
+
+ expect(screen.getByText('2.0M toks')).toBeInTheDocument();
});
});
diff --git a/ui/tests/unit/components/analytics/usage-summary-cards.test.tsx b/ui/tests/unit/components/analytics/usage-summary-cards.test.tsx
new file mode 100644
index 00000000..c9bcfb5e
--- /dev/null
+++ b/ui/tests/unit/components/analytics/usage-summary-cards.test.tsx
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { UsageSummaryCards } from '@/components/analytics/usage-summary-cards';
+import type { UsageSummary } from '@/hooks/use-usage';
+import { AllProviders } from '@tests/setup/test-utils';
+
+function buildSummary(overrides: Partial = {}): UsageSummary {
+ return {
+ totalTokens: 3_000,
+ totalInputTokens: 2_000,
+ totalOutputTokens: 1_000,
+ totalCacheTokens: 500,
+ totalCacheCreationTokens: 300,
+ totalCacheReadTokens: 200,
+ totalCost: 2,
+ tokenBreakdown: {
+ input: { tokens: 2_000, cost: 0.8 },
+ output: { tokens: 1_000, cost: 0.7 },
+ cacheCreation: { tokens: 300, cost: 0.3 },
+ cacheRead: { tokens: 200, cost: 0.2 },
+ },
+ totalDays: 2,
+ averageTokensPerDay: 1_500,
+ averageCostPerDay: 1,
+ ...overrides,
+ };
+}
+
+describe('UsageSummaryCards', () => {
+ it('labels total tokens as I/O-only when cache is excluded from the backend total', () => {
+ render(, { wrapper: AllProviders });
+
+ expect(screen.getByText('Total Tokens (I/O)')).toBeInTheDocument();
+ expect(screen.getByText('2.0K in / 1.0K out')).toBeInTheDocument();
+ expect(screen.getByText('Cache Tokens')).toBeInTheDocument();
+ expect(screen.getByText('$0.50 (25% of cost)')).toBeInTheDocument();
+ });
+
+ it('falls back to an inclusive total label when total tokens already include cache', () => {
+ render(
+ ,
+ { wrapper: AllProviders }
+ );
+
+ expect(screen.getByText('Total Tokens')).toBeInTheDocument();
+ expect(screen.getByText('2.0K in / 1.0K out / 500 cache')).toBeInTheDocument();
+ expect(screen.queryByText('Total Tokens (I/O)')).not.toBeInTheDocument();
+ });
+});
diff --git a/ui/tests/unit/components/analytics/usage-trend-chart.test.tsx b/ui/tests/unit/components/analytics/usage-trend-chart.test.tsx
new file mode 100644
index 00000000..29da8057
--- /dev/null
+++ b/ui/tests/unit/components/analytics/usage-trend-chart.test.tsx
@@ -0,0 +1,66 @@
+import type { ReactNode } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { UsageTrendChart } from '@/components/analytics/usage-trend-chart';
+import { AllProviders } from '@tests/setup/test-utils';
+
+vi.mock('recharts', () => ({
+ ResponsiveContainer: ({ children }: { children: ReactNode }) => {children}
,
+ AreaChart: ({ children }: { children: ReactNode }) => {children}
,
+ Area: () => null,
+ CartesianGrid: () => null,
+ XAxis: () => null,
+ YAxis: () => null,
+ Tooltip: ({ content }: { content: (args: unknown) => ReactNode }) => (
+
+ {content({
+ active: true,
+ label: '14:00',
+ payload: [
+ {
+ name: 'Tokens',
+ value: 1_200,
+ color: '#0080FF',
+ payload: { requests: 4 },
+ },
+ {
+ name: 'Cost',
+ value: 1.5,
+ color: '#00C49F',
+ payload: { requests: 4 },
+ },
+ ],
+ })}
+
+ ),
+ defs: ({ children }: { children: ReactNode }) => <>{children}>,
+ linearGradient: ({ children }: { children: ReactNode }) => <>{children}>,
+ stop: () => null,
+}));
+
+describe('UsageTrendChart', () => {
+ it('does not overclaim hourly request semantics in the tooltip', () => {
+ render(
+ ,
+ { wrapper: AllProviders }
+ );
+
+ expect(screen.getByText('Tokens: 1.2K')).toBeInTheDocument();
+ expect(screen.getByText('Cost: $1.5')).toBeInTheDocument();
+ expect(screen.queryByText(/Requests:/)).not.toBeInTheDocument();
+ });
+});
diff --git a/ui/tests/unit/components/cliproxy/routing-guidance-card.test.tsx b/ui/tests/unit/components/cliproxy/routing-guidance-card.test.tsx
index f22a8aae..6fc4e1f3 100644
--- a/ui/tests/unit/components/cliproxy/routing-guidance-card.test.tsx
+++ b/ui/tests/unit/components/cliproxy/routing-guidance-card.test.tsx
@@ -5,6 +5,7 @@ import { RoutingGuidanceCard } from '@/components/cliproxy/routing-guidance-card
describe('RoutingGuidanceCard', () => {
it('shows the current strategy and applies an explicit change', async () => {
const onApply = vi.fn();
+ const onApplyAffinity = vi.fn();
render(
{
target: 'local',
reachable: true,
}}
+ sessionAffinityState={{
+ enabled: true,
+ ttl: '1h',
+ source: 'config',
+ target: 'local',
+ reachable: true,
+ manageable: true,
+ }}
isLoading={false}
isSaving={false}
onApply={onApply}
+ onApplyAffinity={onApplyAffinity}
/>
);
expect(screen.getByText('Routing strategy')).toBeInTheDocument();
expect(screen.getAllByText('round-robin').length).toBeGreaterThan(0);
+ expect(screen.getByText('Session affinity')).toBeInTheDocument();
+ expect(screen.getByDisplayValue('1h')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /fill first/i }));
fireEvent.click(screen.getByRole('button', { name: /use fill-first/i }));
expect(onApply).toHaveBeenCalledWith('fill-first');
+
+ fireEvent.click(screen.getByRole('button', { name: /disable session affinity/i }));
+ expect(onApplyAffinity).toHaveBeenCalledWith({ enabled: false, ttl: '1h' });
});
it('shows the error state and disables apply', () => {
@@ -36,10 +51,40 @@ describe('RoutingGuidanceCard', () => {
isSaving={false}
error={new Error('Remote CLIProxy is not reachable')}
onApply={() => undefined}
+ onApplyAffinity={() => undefined}
/>
);
expect(screen.getByText('Remote CLIProxy is not reachable')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /use round-robin/i })).toBeDisabled();
});
+
+ it('shows remote session-affinity guidance when the setting is not manageable', () => {
+ render(
+ undefined}
+ onApplyAffinity={() => undefined}
+ />
+ );
+
+ expect(
+ screen.getByText('Remote session-affinity management is not supported from CCS yet.')
+ ).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /session affinity unavailable/i })).toBeDisabled();
+ });
});
diff --git a/ui/tests/unit/components/compatible-cli/codex-top-level-controls-card.test.tsx b/ui/tests/unit/components/compatible-cli/codex-top-level-controls-card.test.tsx
index 803d48e3..fab41a28 100644
--- a/ui/tests/unit/components/compatible-cli/codex-top-level-controls-card.test.tsx
+++ b/ui/tests/unit/components/compatible-cli/codex-top-level-controls-card.test.tsx
@@ -28,7 +28,7 @@ describe('CodexTopLevelControlsCard', () => {
const saveButton = screen.getByRole('button', { name: 'Save top-level settings' });
expect(saveButton).toBeDisabled();
- await userEvent.type(screen.getByPlaceholderText('gpt-5.4'), 'gpt-5.4-mini');
+ await userEvent.type(screen.getByPlaceholderText('gpt-5.5'), 'gpt-5.4-mini');
expect(saveButton).toBeEnabled();
await userEvent.click(saveButton);
diff --git a/ui/tests/unit/ui/lib/codex-quota-breakdown.test.ts b/ui/tests/unit/ui/lib/codex-quota-breakdown.test.ts
new file mode 100644
index 00000000..99c6c6e1
--- /dev/null
+++ b/ui/tests/unit/ui/lib/codex-quota-breakdown.test.ts
@@ -0,0 +1,316 @@
+/**
+ * Tests for Codex quota breakdown logic.
+ *
+ * Covers the dashboard-side mirror of the server's CodexQuotaWindow handling:
+ * additional rate-limit windows (e.g. GPT-5.3 Codex Spark) must NOT pollute
+ * the core 5h/weekly buckets, and category metadata takes precedence over
+ * label sniffing when present.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ getCodexQuotaBreakdown,
+ getCodexWindowKind,
+ getCodexWindowDisplayLabel,
+ prettifyCodexFeatureLabel,
+} from '@/lib/utils';
+import type { CodexQuotaWindow } from '@/lib/api-client';
+
+describe('prettifyCodexFeatureLabel', () => {
+ it('strips "GPT-X.Y-Codex-" prefix and prepends "Codex"', () => {
+ expect(prettifyCodexFeatureLabel('GPT-5.3-Codex-Spark')).toBe('Codex Spark');
+ });
+
+ it('is case-insensitive when stripping the GPT prefix', () => {
+ expect(prettifyCodexFeatureLabel('gpt-5.3-codex-spark')).toBe('Codex spark');
+ });
+
+ it('returns labels without the GPT prefix verbatim', () => {
+ expect(prettifyCodexFeatureLabel('OmniReview')).toBe('OmniReview');
+ });
+
+ it('returns empty string for empty input', () => {
+ expect(prettifyCodexFeatureLabel('')).toBe('');
+ });
+
+ it('trims whitespace and handles whitespace-only input', () => {
+ expect(prettifyCodexFeatureLabel(' ')).toBe('');
+ expect(prettifyCodexFeatureLabel(' GPT-5.3-Codex-Spark ')).toBe('Codex Spark');
+ });
+});
+
+describe('getCodexWindowKind', () => {
+ it('uses category metadata when present (additional weekly)', () => {
+ const window: CodexQuotaWindow = {
+ label: 'GPT-5.3-Codex-Spark (Secondary)',
+ usedPercent: 10,
+ remainingPercent: 90,
+ resetAfterSeconds: 600000,
+ resetAt: '2026-05-04T00:00:00Z',
+ category: 'additional',
+ cadence: 'weekly',
+ featureLabel: 'GPT-5.3-Codex-Spark',
+ };
+ expect(getCodexWindowKind(window)).toBe('additional-weekly');
+ });
+
+ it('uses category metadata when present (additional 5h)', () => {
+ const window: CodexQuotaWindow = {
+ label: 'GPT-5.3-Codex-Spark (Primary)',
+ usedPercent: 5,
+ remainingPercent: 95,
+ resetAfterSeconds: 18000,
+ resetAt: '2026-04-28T00:00:00Z',
+ category: 'additional',
+ cadence: '5h',
+ featureLabel: 'GPT-5.3-Codex-Spark',
+ };
+ expect(getCodexWindowKind(window)).toBe('additional-5h');
+ });
+
+ it('uses category metadata when present (usage 5h)', () => {
+ const window: CodexQuotaWindow = {
+ label: 'Primary',
+ usedPercent: 25,
+ remainingPercent: 75,
+ resetAfterSeconds: 18000,
+ resetAt: '2026-04-28T00:00:00Z',
+ category: 'usage',
+ cadence: '5h',
+ };
+ expect(getCodexWindowKind(window)).toBe('usage-5h');
+ });
+
+ it('uses category metadata when present (code-review weekly)', () => {
+ const window: CodexQuotaWindow = {
+ label: 'Code Review (Secondary)',
+ usedPercent: 50,
+ remainingPercent: 50,
+ resetAfterSeconds: 604800,
+ resetAt: '2026-05-04T00:00:00Z',
+ category: 'code-review',
+ cadence: 'weekly',
+ featureLabel: 'Code Review',
+ };
+ expect(getCodexWindowKind(window)).toBe('code-review-weekly');
+ });
+
+ it('falls back to label sniffing for legacy windows without category', () => {
+ expect(getCodexWindowKind('Primary')).toBe('usage-5h');
+ expect(getCodexWindowKind('Secondary')).toBe('usage-weekly');
+ expect(getCodexWindowKind('Code Review (Primary)')).toBe('code-review-5h');
+ expect(getCodexWindowKind('Code Review (Secondary)')).toBe('code-review-weekly');
+ expect(getCodexWindowKind('Code Review')).toBe('code-review');
+ expect(getCodexWindowKind('Random Label')).toBe('unknown');
+ });
+
+ it('falls back to label sniffing when window has no category set', () => {
+ const window: CodexQuotaWindow = {
+ label: 'Primary',
+ usedPercent: 25,
+ remainingPercent: 75,
+ resetAfterSeconds: 18000,
+ resetAt: '2026-04-28T00:00:00Z',
+ };
+ expect(getCodexWindowKind(window)).toBe('usage-5h');
+ });
+});
+
+describe('getCodexQuotaBreakdown', () => {
+ it('returns empty buckets for empty windows', () => {
+ const result = getCodexQuotaBreakdown([]);
+ expect(result.fiveHourWindow).toBeNull();
+ expect(result.weeklyWindow).toBeNull();
+ expect(result.codeReviewWindows).toEqual([]);
+ expect(result.additionalWindows).toEqual([]);
+ expect(result.unknownWindows).toEqual([]);
+ });
+
+ it('routes Pro-account windows by category metadata (Spark stays out of core)', () => {
+ const windows: CodexQuotaWindow[] = [
+ {
+ label: 'Primary',
+ usedPercent: 0,
+ remainingPercent: 100,
+ resetAfterSeconds: 18000,
+ resetAt: '2026-04-28T05:00:00Z',
+ category: 'usage',
+ cadence: '5h',
+ },
+ {
+ label: 'Secondary',
+ usedPercent: 0,
+ remainingPercent: 100,
+ resetAfterSeconds: 604800,
+ resetAt: '2026-05-04T00:00:00Z',
+ category: 'usage',
+ cadence: 'weekly',
+ },
+ {
+ label: 'GPT-5.3-Codex-Spark (Primary)',
+ usedPercent: 12,
+ remainingPercent: 88,
+ // Intentionally short reset to verify Spark cannot displace core 5h via reset-horizon fallback.
+ resetAfterSeconds: 60,
+ resetAt: '2026-04-27T00:01:00Z',
+ category: 'additional',
+ cadence: '5h',
+ featureLabel: 'GPT-5.3-Codex-Spark',
+ },
+ {
+ label: 'GPT-5.3-Codex-Spark (Secondary)',
+ usedPercent: 30,
+ remainingPercent: 70,
+ resetAfterSeconds: 600000,
+ resetAt: '2026-05-04T00:00:00Z',
+ category: 'additional',
+ cadence: 'weekly',
+ featureLabel: 'GPT-5.3-Codex-Spark',
+ },
+ {
+ label: 'Code Review (Primary)',
+ usedPercent: 25,
+ remainingPercent: 75,
+ resetAfterSeconds: 17000,
+ resetAt: '2026-04-28T05:00:00Z',
+ category: 'code-review',
+ cadence: '5h',
+ featureLabel: 'Code Review',
+ },
+ ];
+
+ const result = getCodexQuotaBreakdown(windows);
+
+ expect(result.fiveHourWindow?.label).toBe('Primary');
+ expect(result.fiveHourWindow?.category).toBe('usage');
+ expect(result.weeklyWindow?.label).toBe('Secondary');
+ expect(result.weeklyWindow?.category).toBe('usage');
+
+ expect(result.additionalWindows).toHaveLength(2);
+ expect(result.additionalWindows.map((w) => w.featureLabel)).toEqual([
+ 'GPT-5.3-Codex-Spark',
+ 'GPT-5.3-Codex-Spark',
+ ]);
+ expect(result.additionalWindows.map((w) => w.cadence)).toEqual(['5h', 'weekly']);
+
+ expect(result.codeReviewWindows).toHaveLength(1);
+ expect(result.codeReviewWindows[0]?.featureLabel).toBe('Code Review');
+
+ expect(result.unknownWindows).toEqual([]);
+ });
+
+ it('returns empty additionalWindows for Plus-shape data without additional categories', () => {
+ const windows: CodexQuotaWindow[] = [
+ {
+ label: 'Primary',
+ usedPercent: 30,
+ remainingPercent: 70,
+ resetAfterSeconds: 18000,
+ resetAt: '2026-04-28T05:00:00Z',
+ category: 'usage',
+ cadence: '5h',
+ },
+ {
+ label: 'Secondary',
+ usedPercent: 60,
+ remainingPercent: 40,
+ resetAfterSeconds: 604800,
+ resetAt: '2026-05-04T00:00:00Z',
+ category: 'usage',
+ cadence: 'weekly',
+ },
+ ];
+
+ const result = getCodexQuotaBreakdown(windows);
+
+ expect(result.fiveHourWindow?.label).toBe('Primary');
+ expect(result.weeklyWindow?.label).toBe('Secondary');
+ expect(result.additionalWindows).toEqual([]);
+ expect(result.codeReviewWindows).toEqual([]);
+ expect(result.unknownWindows).toEqual([]);
+ });
+
+ it('falls back to label sniffing for legacy cached windows without metadata', () => {
+ const windows: CodexQuotaWindow[] = [
+ {
+ label: 'Primary',
+ usedPercent: 25,
+ remainingPercent: 75,
+ resetAfterSeconds: 18000,
+ resetAt: '2026-04-28T05:00:00Z',
+ },
+ {
+ label: 'Secondary',
+ usedPercent: 65,
+ remainingPercent: 35,
+ resetAfterSeconds: 604800,
+ resetAt: '2026-05-04T00:00:00Z',
+ },
+ {
+ label: 'Code Review (Primary)',
+ usedPercent: 10,
+ remainingPercent: 90,
+ resetAfterSeconds: 17000,
+ resetAt: '2026-04-28T05:00:00Z',
+ },
+ {
+ label: 'Code Review (Secondary)',
+ usedPercent: 5,
+ remainingPercent: 95,
+ resetAfterSeconds: 600000,
+ resetAt: '2026-05-04T00:00:00Z',
+ },
+ ];
+
+ const result = getCodexQuotaBreakdown(windows);
+
+ expect(result.fiveHourWindow?.label).toBe('Primary');
+ expect(result.weeklyWindow?.label).toBe('Secondary');
+ expect(result.codeReviewWindows).toHaveLength(2);
+ expect(result.additionalWindows).toEqual([]);
+ expect(result.unknownWindows).toEqual([]);
+ });
+});
+
+describe('getCodexWindowDisplayLabel for additional windows', () => {
+ it('renders additional 5h windows with prettified Codex feature name', () => {
+ const window: CodexQuotaWindow = {
+ label: 'GPT-5.3-Codex-Spark (Primary)',
+ usedPercent: 12,
+ remainingPercent: 88,
+ resetAfterSeconds: 18000,
+ resetAt: '2026-04-28T05:00:00Z',
+ category: 'additional',
+ cadence: '5h',
+ featureLabel: 'GPT-5.3-Codex-Spark',
+ };
+ expect(getCodexWindowDisplayLabel(window)).toBe('Codex Spark (5h)');
+ });
+
+ it('renders additional weekly windows with prettified Codex feature name', () => {
+ const window: CodexQuotaWindow = {
+ label: 'GPT-5.3-Codex-Spark (Secondary)',
+ usedPercent: 30,
+ remainingPercent: 70,
+ resetAfterSeconds: 600000,
+ resetAt: '2026-05-04T00:00:00Z',
+ category: 'additional',
+ cadence: 'weekly',
+ featureLabel: 'GPT-5.3-Codex-Spark',
+ };
+ expect(getCodexWindowDisplayLabel(window)).toBe('Codex Spark (weekly)');
+ });
+
+ it('falls back to raw label when featureLabel is missing', () => {
+ const window: CodexQuotaWindow = {
+ label: 'OmniReview (Primary)',
+ usedPercent: 0,
+ remainingPercent: 100,
+ resetAfterSeconds: 18000,
+ resetAt: '2026-04-28T05:00:00Z',
+ category: 'additional',
+ cadence: '5h',
+ };
+ expect(getCodexWindowDisplayLabel(window)).toBe('OmniReview (Primary) (5h)');
+ });
+});
diff --git a/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts b/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts
index b8ceaf9a..4e3f5f94 100644
--- a/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts
+++ b/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts
@@ -4,10 +4,13 @@ import { MODEL_CATALOGS } from '@/lib/model-catalogs';
describe('codex model catalog defaults', () => {
it('uses gpt-5-codex-mini as the haiku mapping for cross-plan codex presets', () => {
const codexCatalog = MODEL_CATALOGS.codex;
+ const codex55 = codexCatalog.models.find((model) => model.id === 'gpt-5.5');
const codex53 = codexCatalog.models.find((model) => model.id === 'gpt-5.3-codex');
const codex52 = codexCatalog.models.find((model) => model.id === 'gpt-5.2-codex');
const codexMini = codexCatalog.models.find((model) => model.id === 'gpt-5-codex-mini');
+ expect(codex55?.presetMapping?.haiku).toBe('gpt-5-codex-mini');
+ expect(codex55?.codexMaxEffort).toBe('xhigh');
expect(codex53?.presetMapping?.haiku).toBe('gpt-5-codex-mini');
expect(codex52?.presetMapping?.haiku).toBe('gpt-5-codex-mini');
expect(codex53?.codexMaxEffort).toBe('xhigh');
diff --git a/ui/tests/unit/ui/pages/analytics/hooks.test.tsx b/ui/tests/unit/ui/pages/analytics/hooks.test.tsx
new file mode 100644
index 00000000..eb4f4455
--- /dev/null
+++ b/ui/tests/unit/ui/pages/analytics/hooks.test.tsx
@@ -0,0 +1,35 @@
+import { describe, expect, it, vi } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import { useAnalyticsPage } from '@/pages/analytics/hooks';
+import { AllProviders } from '@tests/setup/test-utils';
+
+const usageMocks = vi.hoisted(() => ({
+ useUsageSummary: vi.fn(() => ({ data: undefined, isLoading: false })),
+ useUsageTrends: vi.fn(() => ({ data: undefined, isLoading: false })),
+ useHourlyUsage: vi.fn(() => ({ data: undefined, isLoading: false })),
+ useModelUsage: vi.fn(() => ({ data: undefined, isLoading: false })),
+ useRefreshUsage: vi.fn(() => vi.fn()),
+ useUsageStatus: vi.fn(() => ({ data: { lastFetch: null } })),
+ useSessions: vi.fn(() => ({ data: undefined, isLoading: false })),
+}));
+
+vi.mock('@/hooks/use-usage', () => usageMocks);
+
+describe('useAnalyticsPage', () => {
+ it('requests a broader recent session sample instead of the old 3-session slice', () => {
+ renderHook(() => useAnalyticsPage(), {
+ wrapper: AllProviders,
+ });
+
+ expect(usageMocks.useSessions).toHaveBeenCalledWith(
+ expect.objectContaining({
+ limit: 50,
+ })
+ );
+ expect(usageMocks.useSessions).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ limit: 3,
+ })
+ );
+ });
+});
diff --git a/ui/tests/unit/ui/pages/analytics/usage-api-contract.test.ts b/ui/tests/unit/ui/pages/analytics/usage-api-contract.test.ts
new file mode 100644
index 00000000..645151c2
--- /dev/null
+++ b/ui/tests/unit/ui/pages/analytics/usage-api-contract.test.ts
@@ -0,0 +1,40 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { usageApi } from '@/hooks/use-usage';
+
+function jsonResponse(body: unknown) {
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+}
+
+describe('analytics usage API contract', () => {
+ beforeEach(() => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockImplementation(() => Promise.resolve(jsonResponse({ data: {} }))) as typeof fetch
+ );
+ });
+
+ it('serializes only the supported analytics query params', async () => {
+ const startDate = new Date(2026, 3, 1);
+ const endDate = new Date(2026, 3, 30);
+
+ await usageApi.summary({ startDate, endDate });
+ await usageApi.trends({ startDate, endDate });
+ await usageApi.models({ startDate, endDate });
+ await usageApi.sessions({ startDate, endDate, limit: 50, offset: 10 });
+ await usageApi.insights({ startDate, endDate });
+ await usageApi.monthly({ startDate, endDate });
+
+ const urls = vi.mocked(global.fetch).mock.calls.map(([url]) => String(url));
+
+ expect(urls).toContain('/api/usage/summary?since=20260401&until=20260430');
+ expect(urls).toContain('/api/usage/daily?since=20260401&until=20260430');
+ expect(urls).toContain('/api/usage/models?since=20260401&until=20260430');
+ expect(urls).toContain('/api/usage/sessions?since=20260401&until=20260430&limit=50&offset=10');
+ expect(urls).toContain('/api/usage/insights?since=20260401&until=20260430');
+ expect(urls).toContain('/api/usage/monthly?since=20260401&until=20260430');
+ expect(urls.every((url) => !url.includes('months='))).toBe(true);
+ });
+});