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:
gsxdsm
2026-03-30 08:50:41 -07:00
parent dbb9791504
commit 9085e8d867
4 changed files with 334 additions and 20 deletions

View File

@@ -0,0 +1,5 @@
---
"@dustinbyrne/kb": patch
---
Add usage view toggle (used/remaining) and real provider icons to usage dropdown.

View File

@@ -374,4 +374,199 @@ describe("UsageIndicator", () => {
expect(screen.getByText("55% left")).toBeInTheDocument(); expect(screen.getByText("55% left")).toBeInTheDocument();
expect(screen.getByText("resets in 2h")).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();
});
}); });

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
import { X, RefreshCw, Activity } from "lucide-react"; import { X, RefreshCw, Activity } from "lucide-react";
import type { ProviderUsage, UsageWindow } from "../api"; import type { ProviderUsage, UsageWindow } from "../api";
import { useUsageData } from "../hooks/useUsageData"; import { useUsageData } from "../hooks/useUsageData";
import { ProviderIcon } from "./ProviderIcon";
interface UsageIndicatorProps { interface UsageIndicatorProps {
isOpen: boolean; isOpen: boolean;
@@ -20,31 +21,42 @@ function getUsageColorClass(percentUsed: number): string {
return "usage-progress-fill--low"; return "usage-progress-fill--low";
} }
interface UsageWindowRowProps {
window: UsageWindow;
viewMode: 'used' | 'remaining';
}
/** /**
* Single usage window row with progress bar * Single usage window row with progress bar
*/ */
function UsageWindowRow({ window }: { window: UsageWindow }) { function UsageWindowRow({ window, viewMode }: UsageWindowRowProps) {
const colorClass = getUsageColorClass(window.percentUsed); 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 ( return (
<div className="usage-window"> <div className="usage-window">
<div className="usage-window-header"> <div className="usage-window-header">
<span className="usage-window-label">{window.label}</span> <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>
<div className="usage-progress-bar"> <div className="usage-progress-bar">
<div <div
className={`usage-progress-fill ${colorClass}`} className={`usage-progress-fill ${colorClass}`}
style={{ width: `${window.percentUsed}%` }} style={{ width: `${displayPercent}%` }}
role="progressbar" role="progressbar"
aria-valuenow={window.percentUsed} aria-valuenow={displayPercent}
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={100} aria-valuemax={100}
aria-label={`${window.label} usage: ${window.percentUsed}%`} aria-label={`${window.label}: ${headerText}`}
/> />
</div> </div>
<div className="usage-window-footer"> <div className="usage-window-footer">
<span className="usage-window-left">{window.percentLeft}% left</span> <span className="usage-window-left">{footerText}</span>
{window.resetText && ( {window.resetText && (
<span className="usage-window-reset">{window.resetText}</span> <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 * Provider card showing status and usage windows
*/ */
function ProviderCard({ provider }: { provider: ProviderUsage }) { function ProviderCard({ provider, viewMode }: ProviderCardProps) {
const getStatusBadge = () => { const getStatusBadge = () => {
switch (provider.status) { switch (provider.status) {
case "ok": 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" data-provider={provider.name} data-status={provider.status}>
<div className="usage-provider-header"> <div className="usage-provider-header">
<div className="usage-provider-info"> <div className="usage-provider-info">
<span className="usage-provider-icon" role="img" aria-label={provider.name}> <ProviderIcon provider={getProviderIconKey(provider.name)} size="md" />
{provider.icon}
</span>
<span className="usage-provider-name">{provider.name}</span> <span className="usage-provider-name">{provider.name}</span>
</div> </div>
{getStatusBadge()} {getStatusBadge()}
@@ -109,7 +148,7 @@ function ProviderCard({ provider }: { provider: ProviderUsage }) {
{provider.windows.length > 0 ? ( {provider.windows.length > 0 ? (
<div className="usage-provider-windows"> <div className="usage-provider-windows">
{provider.windows.map((window, index) => ( {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> </div>
) : provider.status === "ok" ? ( ) : provider.status === "ok" ? (
@@ -153,8 +192,23 @@ export function UsageIndicator({ isOpen, onClose }: UsageIndicatorProps) {
}); });
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [viewMode, setViewMode] = useState<'used' | 'remaining'>('used');
const contentRef = useRef<HTMLDivElement>(null); 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 // Handle manual refresh
const handleRefresh = useCallback(async () => { const handleRefresh = useCallback(async () => {
setIsRefreshing(true); setIsRefreshing(true);
@@ -196,14 +250,34 @@ export function UsageIndicator({ isOpen, onClose }: UsageIndicatorProps) {
<Activity size={18} className="usage-header-icon" /> <Activity size={18} className="usage-header-icon" />
<h3>Usage</h3> <h3>Usage</h3>
</div> </div>
<button <div className="usage-header-actions">
className="modal-close" <div className="usage-view-toggle" role="group" aria-label="Usage view mode">
onClick={onClose} <button
aria-label="Close usage modal" className={`usage-view-toggle-btn ${viewMode === 'used' ? 'active' : ''}`}
data-testid="usage-modal-close" onClick={() => handleViewModeChange('used')}
> aria-pressed={viewMode === 'used'}
<X size={20} /> data-testid="usage-view-toggle-used"
</button> >
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}
aria-label="Close usage modal"
data-testid="usage-modal-close"
>
<X size={20} />
</button>
</div>
</div> </div>
<div className="usage-content" ref={contentRef}> <div className="usage-content" ref={contentRef}>
@@ -227,7 +301,7 @@ export function UsageIndicator({ isOpen, onClose }: UsageIndicatorProps) {
) : ( ) : (
<div className="usage-providers"> <div className="usage-providers">
{providers.map((provider) => ( {providers.map((provider) => (
<ProviderCard key={provider.name} provider={provider} /> <ProviderCard key={provider.name} provider={provider} viewMode={viewMode} />
))} ))}
</div> </div>
)} )}

View File

@@ -6938,6 +6938,46 @@ html .column.drag-over * {
color: var(--in-progress); 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 */ /* Content area */
.usage-content { .usage-content {
max-height: 60vh; max-height: 60vh;