feat(KB-088): clear failed status when moving tasks and improve error display
- Fix moveTask to clear status/error/worktree/blockedBy when moving from in-progress to todo/triage - Add error message display in TaskCard for failed tasks with truncation - Add prominent error alert in TaskDetailModal for failed tasks - Refactor usage tracking: move from app/components to dashboard/src/ - Add usage.ts and usage.test.ts for centralized usage tracking - Remove UsageIndicator and useUsageData from app/ directory - Update styles.css for error display components - Update executor tests for error handling - Add changeset for patch release
This commit is contained in:
@@ -412,6 +412,12 @@ export function TaskCard({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isFailed && task.error && (
|
||||
<div className="card-error" title={task.error}>
|
||||
<span className="card-error-icon">⚠</span>
|
||||
<span className="card-error-text">{task.error.length > 60 ? task.error.slice(0, 60) + "…" : task.error}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="card-title">
|
||||
{task.title || (task.description ? task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "") : task.id)}
|
||||
</div>
|
||||
|
||||
@@ -407,6 +407,15 @@ export function TaskDetailModal({
|
||||
Created {new Date(task.createdAt).toLocaleDateString()} · Updated{" "}
|
||||
{new Date(task.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
{task.status === "failed" && task.error && (
|
||||
<div className="detail-error-alert">
|
||||
<span className="detail-error-icon">⚠</span>
|
||||
<div className="detail-error-content">
|
||||
<div className="detail-error-title">Task Failed</div>
|
||||
<div className="detail-error-message">{task.error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="detail-tabs">
|
||||
<button
|
||||
className={`detail-tab${activeTab === "definition" ? " detail-tab-active" : ""}`}
|
||||
|
||||
@@ -1,464 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { UsageIndicator } from "./UsageIndicator";
|
||||
import * as useUsageDataModule from "../hooks/useUsageData";
|
||||
import type { ProviderUsage } from "../api";
|
||||
|
||||
// Mock the useUsageData hook
|
||||
vi.mock("../hooks/useUsageData", () => ({
|
||||
useUsageData: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUseUsageData = vi.mocked(useUsageDataModule.useUsageData);
|
||||
|
||||
describe("UsageIndicator", () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockRefresh = vi.fn();
|
||||
|
||||
const mockProviders: ProviderUsage[] = [
|
||||
{
|
||||
name: "Anthropic",
|
||||
icon: "🅰️",
|
||||
status: "ok",
|
||||
plan: "Pro",
|
||||
email: "user@example.com",
|
||||
windows: [
|
||||
{
|
||||
label: "Session (5h)",
|
||||
percentUsed: 45,
|
||||
percentLeft: 55,
|
||||
resetText: "resets in 2h 15m",
|
||||
resetMs: 8100000,
|
||||
},
|
||||
{
|
||||
label: "Weekly",
|
||||
percentUsed: 30,
|
||||
percentLeft: 70,
|
||||
resetText: "resets in 3d",
|
||||
resetMs: 259200000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "OpenAI",
|
||||
icon: "🤖",
|
||||
status: "ok",
|
||||
windows: [
|
||||
{
|
||||
label: "Hourly",
|
||||
percentUsed: 75,
|
||||
percentLeft: 25,
|
||||
resetText: "resets in 45m",
|
||||
resetMs: 2700000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Google",
|
||||
icon: "🔍",
|
||||
status: "no-auth",
|
||||
windows: [],
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders nothing when isOpen is false", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: null,
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<UsageIndicator isOpen={false} onClose={mockOnClose} />
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders loading skeleton when loading and no providers", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
lastUpdated: null,
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
// Check for skeleton elements
|
||||
const skeletonElements = document.querySelectorAll(".usage-skeleton");
|
||||
expect(skeletonElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders providers with usage data", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
// Check for provider names
|
||||
expect(screen.getByText("Anthropic")).toBeInTheDocument();
|
||||
expect(screen.getByText("OpenAI")).toBeInTheDocument();
|
||||
expect(screen.getByText("Google")).toBeInTheDocument();
|
||||
|
||||
// Check for status badges
|
||||
expect(screen.getByText("Connected")).toBeInTheDocument();
|
||||
expect(screen.getByText("Not configured")).toBeInTheDocument();
|
||||
|
||||
// Check for usage windows
|
||||
expect(screen.getByText("Session (5h)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Weekly")).toBeInTheDocument();
|
||||
expect(screen.getByText("Hourly")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays correct percentage and progress bars", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
// Check for percentage text
|
||||
expect(screen.getByText("45% used")).toBeInTheDocument();
|
||||
expect(screen.getByText("55% left")).toBeInTheDocument();
|
||||
expect(screen.getByText("75% used")).toBeInTheDocument();
|
||||
|
||||
// Check progress bars have correct widths
|
||||
const progressBars = document.querySelectorAll(".usage-progress-fill");
|
||||
expect(progressBars.length).toBe(3);
|
||||
|
||||
// Check the width style for the first progress bar (45%)
|
||||
const firstBar = progressBars[0] as HTMLElement;
|
||||
expect(firstBar.style.width).toBe("45%");
|
||||
});
|
||||
|
||||
it("applies correct color classes for usage levels", () => {
|
||||
const providersWithDifferentUsage: ProviderUsage[] = [
|
||||
{
|
||||
name: "LowUsage",
|
||||
icon: "✅",
|
||||
status: "ok",
|
||||
windows: [
|
||||
{ label: "Low", percentUsed: 50, percentLeft: 50, resetText: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "MediumUsage",
|
||||
icon: "⚠️",
|
||||
status: "ok",
|
||||
windows: [
|
||||
{ label: "Medium", percentUsed: 80, percentLeft: 20, resetText: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "HighUsage",
|
||||
icon: "🚨",
|
||||
status: "ok",
|
||||
windows: [
|
||||
{ label: "High", percentUsed: 95, percentLeft: 5, resetText: null },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: providersWithDifferentUsage,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const progressBars = document.querySelectorAll(".usage-progress-fill");
|
||||
expect(progressBars.length).toBe(3);
|
||||
|
||||
// Check color classes
|
||||
expect(progressBars[0]).toHaveClass("usage-progress-fill--low");
|
||||
expect(progressBars[1]).toHaveClass("usage-progress-fill--medium");
|
||||
expect(progressBars[2]).toHaveClass("usage-progress-fill--high");
|
||||
});
|
||||
|
||||
it("displays error state when error occurs and no providers", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [],
|
||||
loading: false,
|
||||
error: "Failed to fetch",
|
||||
lastUpdated: null,
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByText("Failed to load usage data")).toBeInTheDocument();
|
||||
expect(screen.getByText("Failed to fetch")).toBeInTheDocument();
|
||||
expect(screen.getByText("Retry")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays empty state when no providers configured", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: null,
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByText("No AI providers configured")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Configure authentication in Settings to see usage data.")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls refresh when refresh button clicked", async () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const refreshBtn = screen.getByTestId("usage-refresh-btn");
|
||||
fireEvent.click(refreshBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onClose when close button clicked", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const closeBtn = screen.getByTestId("usage-modal-close");
|
||||
fireEvent.click(closeBtn);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onClose when overlay clicked", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const overlay = screen.getByTestId("usage-modal-overlay");
|
||||
fireEvent.click(overlay);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onClose when Escape key pressed", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("displays last updated time", () => {
|
||||
const lastUpdated = new Date("2024-01-15T10:30:00");
|
||||
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated,
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByText(/Last updated:/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays provider error messages", () => {
|
||||
const providersWithError: ProviderUsage[] = [
|
||||
{
|
||||
name: "ErrorProvider",
|
||||
icon: "❌",
|
||||
status: "error",
|
||||
error: "Authentication expired",
|
||||
windows: [],
|
||||
},
|
||||
];
|
||||
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: providersWithError,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByText("Error")).toBeInTheDocument();
|
||||
expect(screen.getByText("Authentication expired")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays provider plan and email info", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByText("Pro")).toBeInTheDocument();
|
||||
expect(screen.getByText("user@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays reset timer text", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByText("resets in 2h 15m")).toBeInTheDocument();
|
||||
expect(screen.getByText("resets in 3d")).toBeInTheDocument();
|
||||
expect(screen.getByText("resets in 45m")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays 'no usage data' message for connected provider without windows", () => {
|
||||
const providerWithoutWindows: ProviderUsage[] = [
|
||||
{
|
||||
name: "EmptyProvider",
|
||||
icon: "📊",
|
||||
status: "ok",
|
||||
windows: [],
|
||||
},
|
||||
];
|
||||
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: providerWithoutWindows,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByText("No usage data available")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes autoRefresh option based on isOpen prop", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: null,
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
// When isOpen is true, autoRefresh should be true
|
||||
const { unmount } = render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(mockUseUsageData).toHaveBeenCalledWith({ autoRefresh: true });
|
||||
|
||||
unmount();
|
||||
|
||||
// Reset mock
|
||||
mockUseUsageData.mockClear();
|
||||
|
||||
// Component not rendered when isOpen is false, so this is the important case
|
||||
render(<UsageIndicator isOpen={false} onClose={mockOnClose} />);
|
||||
|
||||
// When isOpen is false, the hook should not be called at all
|
||||
// because the component returns null before the hook
|
||||
expect(mockUseUsageData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables refresh button when loading", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: true,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const refreshBtn = screen.getByTestId("usage-refresh-btn");
|
||||
expect(refreshBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it("renders with correct ARIA attributes", () => {
|
||||
mockUseUsageData.mockReturnValue({
|
||||
providers: mockProviders,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: new Date(),
|
||||
refresh: mockRefresh,
|
||||
});
|
||||
|
||||
render(<UsageIndicator isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
// Check for progressbar role
|
||||
const progressBars = screen.getAllByRole("progressbar");
|
||||
expect(progressBars.length).toBeGreaterThan(0);
|
||||
|
||||
// Check first progressbar has correct aria attributes
|
||||
const firstBar = progressBars[0];
|
||||
expect(firstBar).toHaveAttribute("aria-valuenow", "45");
|
||||
expect(firstBar).toHaveAttribute("aria-valuemin", "0");
|
||||
expect(firstBar).toHaveAttribute("aria-valuemax", "100");
|
||||
expect(firstBar).toHaveAttribute("aria-label");
|
||||
});
|
||||
});
|
||||
@@ -1,293 +0,0 @@
|
||||
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";
|
||||
|
||||
interface UsageIndicatorProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color class for usage percentage
|
||||
* - >90%: high (red/error color)
|
||||
* - >70%: medium (yellow/triage color)
|
||||
* - <=70%: low (green/success color)
|
||||
*/
|
||||
function getUsageColorClass(percentUsed: number): string {
|
||||
if (percentUsed > 90) return "usage-progress-fill--high";
|
||||
if (percentUsed > 70) return "usage-progress-fill--medium";
|
||||
return "usage-progress-fill--low";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format milliseconds to human-readable string
|
||||
* e.g., "2h 15m", "45m", "30s"
|
||||
*/
|
||||
function formatTimeRemaining(ms: number): string {
|
||||
if (ms <= 0) return "resetting...";
|
||||
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) {
|
||||
const remainingHours = hours % 24;
|
||||
if (remainingHours > 0) {
|
||||
return `${days}d ${remainingHours}h`;
|
||||
}
|
||||
return `${days}d`;
|
||||
}
|
||||
|
||||
if (hours > 0) {
|
||||
const remainingMinutes = minutes % 60;
|
||||
if (remainingMinutes > 0) {
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
return `${hours}h`;
|
||||
}
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single usage window row with progress bar
|
||||
*/
|
||||
function UsageWindowRow({ window }: { window: UsageWindow }) {
|
||||
const colorClass = getUsageColorClass(window.percentUsed);
|
||||
|
||||
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>
|
||||
</div>
|
||||
<div className="usage-progress-bar">
|
||||
<div
|
||||
className={`usage-progress-fill ${colorClass}`}
|
||||
style={{ width: `${window.percentUsed}%` }}
|
||||
role="progressbar"
|
||||
aria-valuenow={window.percentUsed}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={`${window.label} usage: ${window.percentUsed}%`}
|
||||
/>
|
||||
</div>
|
||||
<div className="usage-window-footer">
|
||||
<span className="usage-window-left">{window.percentLeft}% left</span>
|
||||
{window.resetText && (
|
||||
<span className="usage-window-reset">{window.resetText}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider card showing status and usage windows
|
||||
*/
|
||||
function ProviderCard({ provider }: { provider: ProviderUsage }) {
|
||||
const getStatusBadge = () => {
|
||||
switch (provider.status) {
|
||||
case "ok":
|
||||
return (
|
||||
<span className="usage-status-badge usage-status-badge--connected">
|
||||
Connected
|
||||
</span>
|
||||
);
|
||||
case "error":
|
||||
return (
|
||||
<span className="usage-status-badge usage-status-badge--error">
|
||||
Error
|
||||
</span>
|
||||
);
|
||||
case "no-auth":
|
||||
default:
|
||||
return (
|
||||
<span className="usage-status-badge usage-status-badge--not-configured">
|
||||
Not configured
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
<span className="usage-provider-name">{provider.name}</span>
|
||||
</div>
|
||||
{getStatusBadge()}
|
||||
</div>
|
||||
|
||||
{provider.error && (
|
||||
<div className="usage-provider-error">
|
||||
{provider.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(provider.plan || provider.email) && (
|
||||
<div className="usage-provider-meta">
|
||||
{provider.plan && <span className="usage-provider-plan">{provider.plan}</span>}
|
||||
{provider.email && <span className="usage-provider-email">{provider.email}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider.windows.length > 0 ? (
|
||||
<div className="usage-provider-windows">
|
||||
{provider.windows.map((window, index) => (
|
||||
<UsageWindowRow key={`${provider.name}-${window.label}-${index}`} window={window} />
|
||||
))}
|
||||
</div>
|
||||
) : provider.status === "ok" ? (
|
||||
<div className="usage-provider-empty">No usage data available</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading skeleton for usage providers
|
||||
*/
|
||||
function UsageSkeleton() {
|
||||
return (
|
||||
<div className="usage-skeleton">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="usage-skeleton-provider">
|
||||
<div className="usage-skeleton-header">
|
||||
<div className="usage-skeleton-icon" />
|
||||
<div className="usage-skeleton-name" />
|
||||
<div className="usage-skeleton-badge" />
|
||||
</div>
|
||||
<div className="usage-skeleton-bar" />
|
||||
<div className="usage-skeleton-text" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage Indicator Modal
|
||||
*
|
||||
* Displays AI provider subscription usage across multiple providers.
|
||||
* Shows hourly and weekly usage windows with percentage bars,
|
||||
* reset timers, and pace indicators.
|
||||
*/
|
||||
export function UsageIndicator({ isOpen, onClose }: UsageIndicatorProps) {
|
||||
const { providers, loading, error, lastUpdated, refresh } = useUsageData({
|
||||
autoRefresh: isOpen, // Only poll when modal is open
|
||||
});
|
||||
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle manual refresh
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
await refresh();
|
||||
setIsRefreshing(false);
|
||||
}, [refresh]);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Close on overlay click
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={handleOverlayClick} data-testid="usage-modal-overlay">
|
||||
<div className="modal usage-modal" data-testid="usage-modal">
|
||||
<div className="modal-header">
|
||||
<div className="usage-header">
|
||||
<Activity size={18} className="usage-header-icon" />
|
||||
<h3>Usage</h3>
|
||||
</div>
|
||||
<button
|
||||
className="modal-close"
|
||||
onClick={onClose}
|
||||
aria-label="Close usage modal"
|
||||
data-testid="usage-modal-close"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="usage-content" ref={contentRef}>
|
||||
{loading && providers.length === 0 ? (
|
||||
<UsageSkeleton />
|
||||
) : error && providers.length === 0 ? (
|
||||
<div className="usage-error">
|
||||
<p>Failed to load usage data</p>
|
||||
<p className="usage-error-message">{error}</p>
|
||||
<button className="btn btn-sm" onClick={handleRefresh}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : providers.length === 0 ? (
|
||||
<div className="usage-empty">
|
||||
<p>No AI providers configured</p>
|
||||
<p className="usage-empty-hint">
|
||||
Configure authentication in Settings to see usage data.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="usage-providers">
|
||||
{providers.map((provider) => (
|
||||
<ProviderCard key={provider.name} provider={provider} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-actions usage-actions">
|
||||
<div className="usage-last-updated">
|
||||
{lastUpdated && (
|
||||
<span>Last updated: {lastUpdated.toLocaleTimeString()}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading || isRefreshing}
|
||||
data-testid="usage-refresh-btn"
|
||||
>
|
||||
<RefreshCw size={14} className={isRefreshing ? "spin" : ""} />
|
||||
Refresh
|
||||
</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -178,123 +178,6 @@ describe("TaskCard failed status", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard error display", () => {
|
||||
const noopToast = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "KB-099",
|
||||
description: "Test task",
|
||||
column: "in-progress" as Column,
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("renders error message when task has failed status and error field", () => {
|
||||
const task = makeTask({
|
||||
status: "failed",
|
||||
error: "Build failed: cannot find module",
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const errorElement = screen.getByText("Build failed: cannot find module");
|
||||
expect(errorElement).toBeDefined();
|
||||
});
|
||||
|
||||
it("truncates long error messages to 60 characters", () => {
|
||||
const longError = "This is a very long error message that should be truncated because it exceeds sixty characters";
|
||||
const task = makeTask({
|
||||
status: "failed",
|
||||
error: longError,
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show truncated text with ellipsis
|
||||
const truncatedText = "This is a very long error message that should be truncat…";
|
||||
const errorElement = screen.getByText(truncatedText);
|
||||
expect(errorElement).toBeDefined();
|
||||
});
|
||||
|
||||
it("does NOT render error section when task is not failed", () => {
|
||||
const task = makeTask({
|
||||
status: "executing",
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const errorSection = document.querySelector(".card-error");
|
||||
expect(errorSection).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT render error section when task is failed but has no error message", () => {
|
||||
const task = makeTask({
|
||||
status: "failed",
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const errorSection = document.querySelector(".card-error");
|
||||
expect(errorSection).toBeNull();
|
||||
});
|
||||
|
||||
it("error section has tooltip with full error message", () => {
|
||||
const errorMessage = "Full error message for tooltip";
|
||||
const task = makeTask({
|
||||
status: "failed",
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
/>
|
||||
);
|
||||
|
||||
const errorSection = document.querySelector(".card-error");
|
||||
expect(errorSection).toBeDefined();
|
||||
expect(errorSection?.getAttribute("title")).toBe(errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard dependency tooltip", () => {
|
||||
/** Mirrors the data-tooltip computation from TaskCard.tsx */
|
||||
function computeDepTooltip(dependencies: string[]): string | undefined {
|
||||
|
||||
@@ -165,105 +165,6 @@ describe("TaskDetailModal", () => {
|
||||
expect(screen.queryByText("Retry")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders Retry button for failed tasks in any column (including done)", () => {
|
||||
const columns: Column[] = ["triage", "todo", "in-progress", "in-review", "done"];
|
||||
|
||||
for (const column of columns) {
|
||||
const { unmount } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ status: "failed", column })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
onRetryTask={noopRetry}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Retry")).toBeTruthy();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders error alert when task has failed status and error field", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ status: "failed", error: "Build failed: cannot find module" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const errorAlert = container.querySelector(".detail-error-alert");
|
||||
expect(errorAlert).toBeTruthy();
|
||||
expect(screen.getByText("Task Failed")).toBeTruthy();
|
||||
expect(screen.getByText("Build failed: cannot find module")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT render error alert when task is not failed", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ status: "executing" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const errorAlert = container.querySelector(".detail-error-alert");
|
||||
expect(errorAlert).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT render error alert when task is failed but has no error message", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ status: "failed", error: undefined })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const errorAlert = container.querySelector(".detail-error-alert");
|
||||
expect(errorAlert).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onRetryTask when Retry button is clicked", async () => {
|
||||
const mockRetry = vi.fn().mockResolvedValue({});
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ status: "failed", column: "done" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
onRetryTask={mockRetry}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const retryButton = screen.getByText("Retry");
|
||||
fireEvent.click(retryButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRetry).toHaveBeenCalledWith("KB-099");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows description exactly once for a task without title", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
|
||||
Reference in New Issue
Block a user