feat(KB-153): add usage view mode toggle and provider icons
- Add 'used' vs 'remaining' view mode toggle with localStorage persistence - Integrate ProviderIcon component for visual provider identification - Add getProviderIconKey helper to map provider names to icon keys - Update UsageWindowRow to display percentages based on selected view mode - Add comprehensive tests for view mode toggle and icon rendering
This commit is contained in:
5
.changeset/usage-toggle-and-icons.md
Normal file
5
.changeset/usage-toggle-and-icons.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@dustinbyrne/kb": patch
|
||||
---
|
||||
|
||||
Add usage view toggle (used/remaining) and real provider icons to usage dropdown.
|
||||
@@ -374,4 +374,199 @@ describe("UsageIndicator", () => {
|
||||
expect(screen.getByText("55% left")).toBeInTheDocument();
|
||||
expect(screen.getByText("resets in 2h")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// View mode toggle tests
|
||||
it("renders view mode toggle buttons with correct initial state", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const usedBtn = screen.getByTestId("usage-view-toggle-used");
|
||||
const remainingBtn = screen.getByTestId("usage-view-toggle-remaining");
|
||||
|
||||
expect(usedBtn).toBeInTheDocument();
|
||||
expect(remainingBtn).toBeInTheDocument();
|
||||
expect(usedBtn).toHaveClass("active");
|
||||
expect(remainingBtn).not.toHaveClass("active");
|
||||
});
|
||||
|
||||
it("switches view mode when toggle buttons are clicked", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [
|
||||
{
|
||||
name: "TestProvider",
|
||||
icon: "🧪",
|
||||
status: "ok",
|
||||
windows: [
|
||||
{ label: "Session", percentUsed: 45, percentLeft: 55, resetText: "resets in 2h" },
|
||||
],
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const usedBtn = screen.getByTestId("usage-view-toggle-used");
|
||||
const remainingBtn = screen.getByTestId("usage-view-toggle-remaining");
|
||||
|
||||
// Initially shows "used" view
|
||||
expect(screen.getByText("45% used")).toBeInTheDocument();
|
||||
|
||||
// Click remaining button
|
||||
fireEvent.click(remainingBtn);
|
||||
|
||||
// Now should show "remaining" view
|
||||
expect(remainingBtn).toHaveClass("active");
|
||||
expect(usedBtn).not.toHaveClass("active");
|
||||
expect(screen.getByText("55% remaining")).toBeInTheDocument();
|
||||
expect(screen.getByText("45% used")).toBeInTheDocument(); // Footer text
|
||||
|
||||
// Click back to used
|
||||
fireEvent.click(usedBtn);
|
||||
|
||||
expect(usedBtn).toHaveClass("active");
|
||||
expect(remainingBtn).not.toHaveClass("active");
|
||||
expect(screen.getByText("45% used")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reads view mode from localStorage on mount", () => {
|
||||
// Set localStorage to 'remaining' before rendering
|
||||
localStorage.setItem("kb-usage-view-mode", "remaining");
|
||||
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [
|
||||
{
|
||||
name: "TestProvider",
|
||||
icon: "🧪",
|
||||
status: "ok",
|
||||
windows: [
|
||||
{ label: "Session", percentUsed: 45, percentLeft: 55, resetText: "resets in 2h" },
|
||||
],
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const usedBtn = screen.getByTestId("usage-view-toggle-used");
|
||||
const remainingBtn = screen.getByTestId("usage-view-toggle-remaining");
|
||||
|
||||
// Should initialize to 'remaining' from localStorage
|
||||
expect(remainingBtn).toHaveClass("active");
|
||||
expect(usedBtn).not.toHaveClass("active");
|
||||
expect(screen.getByText("55% remaining")).toBeInTheDocument();
|
||||
|
||||
// Clean up
|
||||
localStorage.removeItem("kb-usage-view-mode");
|
||||
});
|
||||
|
||||
it("persists view mode to localStorage when changed", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const remainingBtn = screen.getByTestId("usage-view-toggle-remaining");
|
||||
|
||||
// Click remaining button
|
||||
fireEvent.click(remainingBtn);
|
||||
|
||||
// Should save to localStorage
|
||||
expect(localStorage.getItem("kb-usage-view-mode")).toBe("remaining");
|
||||
|
||||
// Clean up
|
||||
localStorage.removeItem("kb-usage-view-mode");
|
||||
});
|
||||
|
||||
// ProviderIcon integration tests
|
||||
it("renders SVG provider icons instead of emoji", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [
|
||||
{ name: "Anthropic", icon: "🅰️", status: "ok", windows: [] },
|
||||
{ name: "OpenAI", icon: "🤖", status: "ok", windows: [] },
|
||||
{ name: "Google", icon: "🔍", status: "ok", windows: [] },
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
// Should render SVG icons with correct provider data attributes
|
||||
expect(document.querySelector('[data-provider="anthropic"]')).toBeInTheDocument();
|
||||
expect(document.querySelector('[data-provider="openai"]')).toBeInTheDocument();
|
||||
expect(document.querySelector('[data-provider="google"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("maps Claude provider to anthropic icon", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [
|
||||
{ name: "Claude", icon: "🅰️", status: "ok", windows: [] },
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(document.querySelector('[data-provider="anthropic"]')).toBeInTheDocument();
|
||||
expect(document.querySelector("svg[aria-label='Anthropic']")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("maps Codex provider to openai icon", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [
|
||||
{ name: "Codex", icon: "🤖", status: "ok", windows: [] },
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(document.querySelector('[data-provider="openai"]')).toBeInTheDocument();
|
||||
expect(document.querySelector("svg[aria-label='OpenAI']")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("maps Gemini provider to google icon", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [
|
||||
{ name: "Gemini", icon: "🔍", status: "ok", windows: [] },
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(document.querySelector('[data-provider="google"]')).toBeInTheDocument();
|
||||
expect(document.querySelector("svg[aria-label='Google Gemini']")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { X, RefreshCw, Activity } from "lucide-react";
|
||||
import type { ProviderUsage, UsageWindow } from "../api";
|
||||
import { useUsageData } from "../hooks/useUsageData";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
|
||||
interface UsageIndicatorProps {
|
||||
isOpen: boolean;
|
||||
@@ -20,31 +21,42 @@ function getUsageColorClass(percentUsed: number): string {
|
||||
return "usage-progress-fill--low";
|
||||
}
|
||||
|
||||
interface UsageWindowRowProps {
|
||||
window: UsageWindow;
|
||||
viewMode: 'used' | 'remaining';
|
||||
}
|
||||
|
||||
/**
|
||||
* Single usage window row with progress bar
|
||||
*/
|
||||
function UsageWindowRow({ window }: { window: UsageWindow }) {
|
||||
function UsageWindowRow({ window, viewMode }: UsageWindowRowProps) {
|
||||
const colorClass = getUsageColorClass(window.percentUsed);
|
||||
const isRemainingMode = viewMode === 'remaining';
|
||||
|
||||
// Display percentage based on view mode, but color always based on actual usage
|
||||
const displayPercent = isRemainingMode ? window.percentLeft : window.percentUsed;
|
||||
const headerText = isRemainingMode ? `${window.percentLeft}% remaining` : `${window.percentUsed}% used`;
|
||||
const footerText = isRemainingMode ? `${window.percentUsed}% used` : `${window.percentLeft}% left`;
|
||||
|
||||
return (
|
||||
<div className="usage-window">
|
||||
<div className="usage-window-header">
|
||||
<span className="usage-window-label">{window.label}</span>
|
||||
<span className="usage-window-percentage">{window.percentUsed}% used</span>
|
||||
<span className="usage-window-percentage">{headerText}</span>
|
||||
</div>
|
||||
<div className="usage-progress-bar">
|
||||
<div
|
||||
className={`usage-progress-fill ${colorClass}`}
|
||||
style={{ width: `${window.percentUsed}%` }}
|
||||
style={{ width: `${displayPercent}%` }}
|
||||
role="progressbar"
|
||||
aria-valuenow={window.percentUsed}
|
||||
aria-valuenow={displayPercent}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={`${window.label} usage: ${window.percentUsed}%`}
|
||||
aria-label={`${window.label}: ${headerText}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="usage-window-footer">
|
||||
<span className="usage-window-left">{window.percentLeft}% left</span>
|
||||
<span className="usage-window-left">{footerText}</span>
|
||||
{window.resetText && (
|
||||
<span className="usage-window-reset">{window.resetText}</span>
|
||||
)}
|
||||
@@ -53,10 +65,39 @@ function UsageWindowRow({ window }: { window: UsageWindow }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface ProviderCardProps {
|
||||
provider: ProviderUsage;
|
||||
viewMode: 'used' | 'remaining';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map provider names to ProviderIcon provider keys
|
||||
*/
|
||||
function getProviderIconKey(providerName: string): string {
|
||||
const normalized = providerName.toLowerCase();
|
||||
|
||||
// Map common provider names to their icon keys
|
||||
if (normalized.includes('claude') || normalized.includes('anthropic')) {
|
||||
return 'anthropic';
|
||||
}
|
||||
if (normalized.includes('codex') || normalized.includes('openai') || normalized.includes('gpt')) {
|
||||
return 'openai';
|
||||
}
|
||||
if (normalized.includes('gemini') || normalized.includes('google')) {
|
||||
return 'google';
|
||||
}
|
||||
if (normalized.includes('ollama')) {
|
||||
return 'ollama';
|
||||
}
|
||||
|
||||
// Return the original name as fallback (ProviderIcon will show a default icon)
|
||||
return providerName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider card showing status and usage windows
|
||||
*/
|
||||
function ProviderCard({ provider }: { provider: ProviderUsage }) {
|
||||
function ProviderCard({ provider, viewMode }: ProviderCardProps) {
|
||||
const getStatusBadge = () => {
|
||||
switch (provider.status) {
|
||||
case "ok":
|
||||
@@ -85,9 +126,7 @@ function ProviderCard({ provider }: { provider: ProviderUsage }) {
|
||||
<div className="usage-provider" data-provider={provider.name} data-status={provider.status}>
|
||||
<div className="usage-provider-header">
|
||||
<div className="usage-provider-info">
|
||||
<span className="usage-provider-icon" role="img" aria-label={provider.name}>
|
||||
{provider.icon}
|
||||
</span>
|
||||
<ProviderIcon provider={getProviderIconKey(provider.name)} size="md" />
|
||||
<span className="usage-provider-name">{provider.name}</span>
|
||||
</div>
|
||||
{getStatusBadge()}
|
||||
@@ -109,7 +148,7 @@ function ProviderCard({ provider }: { provider: ProviderUsage }) {
|
||||
{provider.windows.length > 0 ? (
|
||||
<div className="usage-provider-windows">
|
||||
{provider.windows.map((window, index) => (
|
||||
<UsageWindowRow key={`${provider.name}-${window.label}-${index}`} window={window} />
|
||||
<UsageWindowRow key={`${provider.name}-${window.label}-${index}`} window={window} viewMode={viewMode} />
|
||||
))}
|
||||
</div>
|
||||
) : provider.status === "ok" ? (
|
||||
@@ -153,8 +192,23 @@ export function UsageIndicator({ isOpen, onClose }: UsageIndicatorProps) {
|
||||
});
|
||||
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'used' | 'remaining'>('used');
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load view mode preference from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedMode = localStorage.getItem('kb-usage-view-mode');
|
||||
if (savedMode === 'used' || savedMode === 'remaining') {
|
||||
setViewMode(savedMode);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Persist view mode to localStorage when it changes
|
||||
const handleViewModeChange = useCallback((mode: 'used' | 'remaining') => {
|
||||
setViewMode(mode);
|
||||
localStorage.setItem('kb-usage-view-mode', mode);
|
||||
}, []);
|
||||
|
||||
// Handle manual refresh
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
@@ -196,6 +250,25 @@ export function UsageIndicator({ isOpen, onClose }: UsageIndicatorProps) {
|
||||
<Activity size={18} className="usage-header-icon" />
|
||||
<h3>Usage</h3>
|
||||
</div>
|
||||
<div className="usage-header-actions">
|
||||
<div className="usage-view-toggle" role="group" aria-label="Usage view mode">
|
||||
<button
|
||||
className={`usage-view-toggle-btn ${viewMode === 'used' ? 'active' : ''}`}
|
||||
onClick={() => handleViewModeChange('used')}
|
||||
aria-pressed={viewMode === 'used'}
|
||||
data-testid="usage-view-toggle-used"
|
||||
>
|
||||
Used
|
||||
</button>
|
||||
<button
|
||||
className={`usage-view-toggle-btn ${viewMode === 'remaining' ? 'active' : ''}`}
|
||||
onClick={() => handleViewModeChange('remaining')}
|
||||
aria-pressed={viewMode === 'remaining'}
|
||||
data-testid="usage-view-toggle-remaining"
|
||||
>
|
||||
Remaining
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="modal-close"
|
||||
onClick={onClose}
|
||||
@@ -205,6 +278,7 @@ export function UsageIndicator({ isOpen, onClose }: UsageIndicatorProps) {
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="usage-content" ref={contentRef}>
|
||||
{loading && providers.length === 0 ? (
|
||||
@@ -227,7 +301,7 @@ export function UsageIndicator({ isOpen, onClose }: UsageIndicatorProps) {
|
||||
) : (
|
||||
<div className="usage-providers">
|
||||
{providers.map((provider) => (
|
||||
<ProviderCard key={provider.name} provider={provider} />
|
||||
<ProviderCard key={provider.name} provider={provider} viewMode={viewMode} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6938,6 +6938,46 @@ html .column.drag-over * {
|
||||
color: var(--in-progress);
|
||||
}
|
||||
|
||||
/* Header actions with view toggle */
|
||||
.usage-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* View mode toggle */
|
||||
.usage-view-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: var(--card);
|
||||
padding: 3px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.usage-view-toggle-btn {
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.usage-view-toggle-btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.usage-view-toggle-btn.active {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
.usage-content {
|
||||
max-height: 60vh;
|
||||
|
||||
Reference in New Issue
Block a user