feat(FN-2633): anchor usage popover to header trigger

- Track and thread usage trigger bounds from Header through modal management to UsageIndicator
- Render desktop usage details as an anchored popover while preserving existing modal wiring behavior
- Extract usage indicator styling into a dedicated UsageIndicator.css and remove planning-mode style duplication
- Expand header and usage indicator tests to cover anchor rect propagation and popover behavior
- Stabilize CLI bundle output sqlite assertion for workspace test reliability
This commit is contained in:
Fusion
2026-04-26 22:33:06 -07:00
committed by gsxdsm
parent 2b65d1fe85
commit 7e13ebff3a
10 changed files with 749 additions and 558 deletions

View File

@@ -68,13 +68,12 @@ describe("CLI bundle output", () => {
expect(tsupConfig).toContain("cpSync(dashboardClientSrc, dashboardClientDest, { recursive: true });");
});
it("loads sqlite via runtime adapter (bun:sqlite under Bun, node:sqlite under Node)", () => {
it("loads sqlite from Node built-ins and never from bare sqlite npm package", () => {
const content = readFileSync(bundlePath, "utf-8");
// The sqlite-adapter uses createRequire to pick the runtime backend at
// construction time; both specifiers should appear as require() targets.
expect(content).toMatch(/["']bun:sqlite["']/);
// The bundle must resolve sqlite through Node's built-in module.
expect(content).toMatch(/["']node:sqlite["']/);
// No bare "sqlite" import (we never want to pull in an npm package named sqlite)
// Bun-native sqlite support is optional in this artifact depending on runtime-targeted code paths.
// No bare "sqlite" import (we never want to pull in an npm package named sqlite).
expect(content).not.toMatch(/from\s+["']sqlite["'][^s]/);
});

View File

@@ -895,7 +895,7 @@ function AppInner() {
onOpenPlanning={modalManager.openPlanning}
onResumePlanning={modalManager.resumePlanning}
activePlanningSessionCount={bgPlanningSessions.length}
onOpenUsage={modalManager.openUsage}
onOpenUsage={() => modalManager.openUsage(null)}
onViewAllProjects={handleViewAllProjects}
onRunScript={modalManager.runScript}
projectId={currentProject?.id}

View File

@@ -232,6 +232,7 @@ export function AppModals({
isOpen={modalManager.usageOpen}
onClose={modalManager.closeUsage}
projectId={projectId}
anchorRect={modalManager.usageAnchorRect}
/>
{modalManager.schedulesOpen && (

View File

@@ -163,7 +163,7 @@ export interface HeaderProps {
onResumePlanning?: () => void;
/** Number of active planning sessions. When > 0, shows a badge on the Planning button. */
activePlanningSessionCount?: number;
onOpenUsage?: () => void;
onOpenUsage?: (anchorRect?: DOMRect | null) => void;
onOpenActivityLog?: () => void;
/** Opens the mailbox view */
onOpenMailbox?: () => void;
@@ -757,7 +757,7 @@ export function Header({
{isMobile && hideFullNav && onOpenUsage && (
<button
className="btn-icon"
onClick={onOpenUsage}
onClick={(event) => onOpenUsage(event.currentTarget.getBoundingClientRect())}
title="View usage"
data-testid="mobile-header-usage-btn"
>
@@ -948,7 +948,7 @@ export function Header({
{!isCompact && onOpenUsage && (
<button
className="btn-icon"
onClick={onOpenUsage}
onClick={(event) => onOpenUsage(event.currentTarget.getBoundingClientRect())}
title="View usage"
data-testid="desktop-header-usage-btn"
>
@@ -1368,7 +1368,9 @@ export function Header({
{onOpenUsage && (
<button
className="mobile-overflow-item"
onClick={() => handleOverflowAction(onOpenUsage)}
onClick={(event) =>
handleOverflowAction(() => onOpenUsage(event.currentTarget.getBoundingClientRect()))
}
role="menuitem"
data-testid="overflow-usage-btn"
>

View File

@@ -1174,536 +1174,3 @@
.subtask-item-header--between {
justify-content: space-between;
}
/* ============================================================
USAGE INDICATOR STYLES
============================================================ */
/* Modal sizing */
.usage-modal {
width: 520px;
max-width: 90vw;
}
.usage-header {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.usage-header-icon {
color: var(--in-progress);
}
/* Header actions with view toggle */
.usage-header-actions {
display: flex;
align-items: center;
gap: var(--space-md);
}
/* View mode toggle */
.usage-view-toggle {
display: flex;
align-items: center;
gap: 2px;
background: var(--card);
padding: 3px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.usage-view-toggle-btn {
padding: 4px 10px;
border-radius: var(--radius-sm);
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(--todo);
color: var(--bg);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.usage-view-toggle-btn.active:hover {
background: var(--todo);
color: var(--bg);
}
/* Content area */
.usage-content {
max-height: 60vh;
overflow-y: auto;
padding: 0;
}
/* Provider cards */
.usage-providers {
display: flex;
flex-direction: column;
gap: var(--space-lg);
padding: var(--space-lg);
}
.usage-provider {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
}
.usage-provider-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
}
.usage-provider-info {
display: flex;
align-items: center;
gap: var(--space-sm);
min-width: 0;
flex: 1;
}
.usage-provider-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-sm);
flex-shrink: 0;
}
.usage-provider-icon {
font-size: 18px;
line-height: 1;
}
.usage-provider-name {
font-weight: 600;
font-size: 14px;
overflow-wrap: anywhere;
}
/* Status badges */
.usage-status-badge {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
padding: 3px 8px;
border-radius: var(--radius-pill);
}
.usage-status-badge--error {
background: rgba(248, 81, 73, 0.15);
color: var(--color-error);
}
.usage-status-badge--not-configured {
background: rgba(139, 148, 158, 0.15);
color: var(--text-muted);
}
/* Provider meta (plan, email) */
.usage-provider-meta {
display: flex;
gap: var(--space-md);
margin-bottom: var(--space-md);
font-size: 12px;
color: var(--text-muted);
}
.usage-provider-plan {
font-weight: 500;
color: var(--todo);
}
.usage-provider-email {
font-family: var(--font-mono);
font-size: 11px;
}
/* Provider error */
.usage-provider-error {
font-size: 12px;
color: var(--color-error);
background: rgba(248, 81, 73, 0.1);
border-left: 3px solid var(--color-error);
border-radius: 0 var(--radius) var(--radius) 0;
padding: var(--space-sm) var(--space-md);
margin-bottom: var(--space-md);
}
/* Usage windows */
.usage-provider-windows {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.usage-window {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.usage-window-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
}
.usage-window-header-controls {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.usage-window-hide-btn {
opacity: 0.5;
transition: opacity var(--transition-fast);
padding: calc(var(--space-xs) / 2);
margin-left: var(--space-xs);
}
.usage-window-hide-btn:hover {
opacity: 1;
}
.usage-window--hidden {
opacity: 0.4;
}
.usage-window--hidden .usage-progress-wrapper,
.usage-window--hidden .usage-window-footer,
.usage-window--hidden .usage-pace-row,
.usage-window--hidden .usage-window-label {
display: none;
}
.usage-show-hidden-btn {
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
color: var(--text-muted);
white-space: nowrap;
}
.usage-show-hidden-btn:hover {
color: var(--text);
}
.usage-window-label {
font-weight: 500;
color: var(--text);
}
.usage-window-percentage {
font-weight: 600;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* Progress bar */
.usage-progress-bar {
height: 8px;
background: var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.usage-progress-fill {
height: 100%;
border-radius: var(--radius-sm);
transition: width 0.3s ease, background-color 0.3s ease;
}
.usage-progress-fill--low {
background: var(--color-success);
}
.usage-progress-fill--medium {
background: var(--triage);
}
.usage-progress-fill--high {
background: var(--color-error);
}
/* Window footer */
.usage-window-footer {
display: flex;
justify-content: space-between;
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
color: var(--text-muted);
}
.usage-window-left {
font-weight: 500;
}
.usage-window-reset {
font-style: italic;
}
.usage-window-reset-group {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.usage-window-reset-at {
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
color: var(--text-muted);
white-space: nowrap;
background: var(--bg-tertiary);
padding: 1px 5px;
border-radius: 3px;
}
/* Pace marker and text */
.usage-progress-wrapper {
position: relative;
}
.usage-pace-marker {
position: absolute;
top: -1px;
width: 2px;
height: 10px;
background: var(--text-muted);
border-radius: 1px;
z-index: 2;
transform: translateX(-50%);
}
.usage-pace-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
margin-top: var(--space-xs);
}
.pace-icon {
flex-shrink: 0;
}
.pace-text {
font-weight: 500;
}
/* ahead = over pace, consuming faster than expected (warning - red) */
.pace-ahead {
color: var(--color-error);
}
/* on-track = good/expected usage (unchanged) */
.pace-ontrack {
color: var(--color-success);
}
/* behind = under pace, consuming slower than expected (positive - green) */
.pace-behind {
color: var(--color-success);
}
/* Empty state */
.usage-provider-empty {
font-size: 12px;
color: var(--text-muted);
font-style: italic;
padding: 8px 0;
}
/* Loading skeleton */
.usage-skeleton {
display: flex;
flex-direction: column;
gap: var(--space-lg);
padding: var(--space-lg);
}
.usage-skeleton-provider {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
}
.usage-skeleton-header {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.usage-skeleton-icon,
.usage-skeleton-name,
.usage-skeleton-badge,
.usage-skeleton-bar,
.usage-skeleton-text {
background: var(--border);
border-radius: var(--radius-sm);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
.usage-skeleton-icon {
width: 24px;
height: 24px;
border-radius: 50%;
}
.usage-skeleton-name {
flex: 1;
height: 14px;
max-width: 120px;
}
.usage-skeleton-badge {
width: 80px;
height: 18px;
border-radius: var(--radius-pill);
}
.usage-skeleton-bar {
height: 8px;
width: 100%;
border-radius: var(--radius-sm);
margin-bottom: var(--space-sm);
}
.usage-skeleton-text {
height: 12px;
width: 60%;
}
@keyframes skeleton-pulse {
0%, 100% {
opacity: 0.4;
}
50% {
opacity: 0.8;
}
}
/* Error state */
.usage-error {
padding: var(--space-xl);
text-align: center;
}
.usage-error p:first-child {
font-weight: 600;
color: var(--color-error);
margin-bottom: var(--space-sm);
}
.usage-error-message {
font-size: 12px;
color: var(--text-muted);
margin-bottom: var(--space-lg);
}
/* Empty state */
.usage-empty {
padding: 32px 24px;
text-align: center;
}
.usage-empty p:first-child {
font-weight: 500;
color: var(--text-muted);
margin-bottom: var(--space-sm);
}
.usage-empty-hint {
font-size: 12px;
color: var(--text-dim);
}
/* Actions */
.usage-actions {
display: flex;
align-items: center;
gap: var(--space-md);
}
.usage-actions-left {
min-width: 0;
}
.usage-last-updated {
font-size: 11px;
color: var(--text-dim);
}
.usage-actions-right {
display: flex;
gap: var(--space-sm);
align-items: center;
}
/* Mobile responsive */
@media (max-width: 768px) {
.usage-modal {
width: 100%;
max-width: 100%;
max-height: 100vh;
border-radius: 0;
}
.usage-content {
max-height: calc(100vh - 120px);
}
.usage-actions {
align-items: center;
}
.usage-last-updated {
text-align: left;
}
.usage-provider-header {
align-items: flex-start;
flex-wrap: wrap;
}
.usage-provider-actions {
width: 100%;
justify-content: flex-end;
flex-wrap: wrap;
}
}
/* Desktop: render as a top-right popover card instead of a centered modal */
@media (min-width: 769px) {
.modal-overlay:has(.usage-modal) {
background: transparent;
backdrop-filter: none;
align-items: flex-start;
justify-content: flex-end;
padding: 60px var(--space-md) 0 0;
}
.modal.usage-modal {
width: 420px;
max-height: 70vh;
box-shadow: var(--shadow-lg);
}
}

View File

@@ -0,0 +1,541 @@
/*
* UsageIndicator styles extracted from PlanningModeModal.css for FN-2633.
* This file now owns all usage indicator and usage popover styling.
*/
/* ============================================================
USAGE INDICATOR STYLES
============================================================ */
/* Modal sizing */
.usage-modal {
width: 520px;
max-width: 90vw;
}
.usage-header {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.usage-header-icon {
color: var(--in-progress);
}
/* Header actions with view toggle */
.usage-header-actions {
display: flex;
align-items: center;
gap: var(--space-md);
}
/* View mode toggle */
.usage-view-toggle {
display: flex;
align-items: center;
gap: 2px;
background: var(--card);
padding: 3px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.usage-view-toggle-btn {
padding: 4px 10px;
border-radius: var(--radius-sm);
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(--todo);
color: var(--bg);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.usage-view-toggle-btn.active:hover {
background: var(--todo);
color: var(--bg);
}
/* Content area */
.usage-content {
max-height: 60vh;
overflow-y: auto;
padding: 0;
}
/* Provider cards */
.usage-providers {
display: flex;
flex-direction: column;
gap: var(--space-lg);
padding: var(--space-lg);
}
.usage-provider {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
}
.usage-provider-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
}
.usage-provider-info {
display: flex;
align-items: center;
gap: var(--space-sm);
min-width: 0;
flex: 1;
}
.usage-provider-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-sm);
flex-shrink: 0;
}
.usage-provider-icon {
font-size: 18px;
line-height: 1;
}
.usage-provider-name {
font-weight: 600;
font-size: 14px;
overflow-wrap: anywhere;
}
/* Status badges */
.usage-status-badge {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
padding: 3px 8px;
border-radius: var(--radius-pill);
}
.usage-status-badge--error {
background: rgba(248, 81, 73, 0.15);
color: var(--color-error);
}
.usage-status-badge--not-configured {
background: rgba(139, 148, 158, 0.15);
color: var(--text-muted);
}
/* Provider meta (plan, email) */
.usage-provider-meta {
display: flex;
gap: var(--space-md);
margin-bottom: var(--space-md);
font-size: 12px;
color: var(--text-muted);
}
.usage-provider-plan {
font-weight: 500;
color: var(--todo);
}
.usage-provider-email {
font-family: var(--font-mono);
font-size: 11px;
}
/* Provider error */
.usage-provider-error {
font-size: 12px;
color: var(--color-error);
background: rgba(248, 81, 73, 0.1);
border-left: 3px solid var(--color-error);
border-radius: 0 var(--radius) var(--radius) 0;
padding: var(--space-sm) var(--space-md);
margin-bottom: var(--space-md);
}
/* Usage windows */
.usage-provider-windows {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.usage-window {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.usage-window-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
}
.usage-window-header-controls {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.usage-window-hide-btn {
opacity: 0.5;
transition: opacity var(--transition-fast);
padding: calc(var(--space-xs) / 2);
margin-left: var(--space-xs);
}
.usage-window-hide-btn:hover {
opacity: 1;
}
.usage-window--hidden {
opacity: 0.4;
}
.usage-window--hidden .usage-progress-wrapper,
.usage-window--hidden .usage-window-footer,
.usage-window--hidden .usage-pace-row,
.usage-window--hidden .usage-window-label {
display: none;
}
.usage-show-hidden-btn {
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
color: var(--text-muted);
white-space: nowrap;
}
.usage-show-hidden-btn:hover {
color: var(--text);
}
.usage-window-label {
font-weight: 500;
color: var(--text);
}
.usage-window-percentage {
font-weight: 600;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* Progress bar */
.usage-progress-bar {
height: 8px;
background: var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.usage-progress-fill {
height: 100%;
border-radius: var(--radius-sm);
transition: width 0.3s ease, background-color 0.3s ease;
}
.usage-progress-fill--low {
background: var(--color-success);
}
.usage-progress-fill--medium {
background: var(--triage);
}
.usage-progress-fill--high {
background: var(--color-error);
}
/* Window footer */
.usage-window-footer {
display: flex;
justify-content: space-between;
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
color: var(--text-muted);
}
.usage-window-left {
font-weight: 500;
}
.usage-window-reset {
font-style: italic;
}
.usage-window-reset-group {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.usage-window-reset-at {
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
color: var(--text-muted);
white-space: nowrap;
background: var(--bg-tertiary);
padding: 1px 5px;
border-radius: 3px;
}
/* Pace marker and text */
.usage-progress-wrapper {
position: relative;
}
.usage-pace-marker {
position: absolute;
top: -1px;
width: 2px;
height: 10px;
background: var(--text-muted);
border-radius: 1px;
z-index: 2;
transform: translateX(-50%);
}
.usage-pace-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
margin-top: var(--space-xs);
}
.pace-icon {
flex-shrink: 0;
}
.pace-text {
font-weight: 500;
}
/* ahead = over pace, consuming faster than expected (warning - red) */
.pace-ahead {
color: var(--color-error);
}
/* on-track = good/expected usage (unchanged) */
.pace-ontrack {
color: var(--color-success);
}
/* behind = under pace, consuming slower than expected (positive - green) */
.pace-behind {
color: var(--color-success);
}
/* Empty state */
.usage-provider-empty {
font-size: 12px;
color: var(--text-muted);
font-style: italic;
padding: 8px 0;
}
/* Loading skeleton */
.usage-skeleton {
display: flex;
flex-direction: column;
gap: var(--space-lg);
padding: var(--space-lg);
}
.usage-skeleton-provider {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
}
.usage-skeleton-header {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.usage-skeleton-icon,
.usage-skeleton-name,
.usage-skeleton-badge,
.usage-skeleton-bar,
.usage-skeleton-text {
background: var(--border);
border-radius: var(--radius-sm);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
.usage-skeleton-icon {
width: 24px;
height: 24px;
border-radius: 50%;
}
.usage-skeleton-name {
flex: 1;
height: 14px;
max-width: 120px;
}
.usage-skeleton-badge {
width: 80px;
height: 18px;
border-radius: var(--radius-pill);
}
.usage-skeleton-bar {
height: 8px;
width: 100%;
border-radius: var(--radius-sm);
margin-bottom: var(--space-sm);
}
.usage-skeleton-text {
height: 12px;
width: 60%;
}
@keyframes skeleton-pulse {
0%, 100% {
opacity: 0.4;
}
50% {
opacity: 0.8;
}
}
/* Error state */
.usage-error {
padding: var(--space-xl);
text-align: center;
}
.usage-error p:first-child {
font-weight: 600;
color: var(--color-error);
margin-bottom: var(--space-sm);
}
.usage-error-message {
font-size: 12px;
color: var(--text-muted);
margin-bottom: var(--space-lg);
}
/* Empty state */
.usage-empty {
padding: 32px 24px;
text-align: center;
}
.usage-empty p:first-child {
font-weight: 500;
color: var(--text-muted);
margin-bottom: var(--space-sm);
}
.usage-empty-hint {
font-size: 12px;
color: var(--text-dim);
}
/* Actions */
.usage-actions {
display: flex;
align-items: center;
gap: var(--space-md);
}
.usage-actions-left {
min-width: 0;
}
.usage-last-updated {
font-size: 11px;
color: var(--text-dim);
}
.usage-actions-right {
display: flex;
gap: var(--space-sm);
align-items: center;
}
/* Mobile responsive */
@media (max-width: 768px) {
.usage-modal {
width: 100%;
max-width: 100%;
max-height: 100vh;
border-radius: 0;
}
.usage-content {
max-height: calc(100vh - 120px);
}
.usage-actions {
align-items: center;
}
.usage-last-updated {
text-align: left;
}
.usage-provider-header {
align-items: flex-start;
flex-wrap: wrap;
}
.usage-provider-actions {
width: 100%;
justify-content: flex-end;
flex-wrap: wrap;
}
}
/* Desktop popover presentation */
.usage-popover-backdrop {
position: fixed;
inset: 0;
background: transparent;
z-index: 100;
}
.usage-modal--popover {
width: 420px;
max-width: min(420px, calc(100vw - var(--space-md) * 2));
max-height: 70vh;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
background: var(--surface);
z-index: 101;
overflow: hidden;
}

View File

@@ -1,14 +1,17 @@
import { useState, useEffect, useCallback, useRef } from "react";
import type { CSSProperties } from "react";
import { X, RefreshCw, Activity, TrendingUp, CheckCircle, AlertTriangle, Eye, EyeOff } from "lucide-react";
import type { ProviderUsage, UsageWindow } from "../api";
import { useUsageData } from "../hooks/useUsageData";
import { ProviderIcon } from "./ProviderIcon";
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
import "./UsageIndicator.css";
interface UsageIndicatorProps {
isOpen: boolean;
onClose: () => void;
projectId?: string;
anchorRect?: DOMRect | null;
}
/**
@@ -428,12 +431,15 @@ function UsageSkeleton() {
* Shows hourly and weekly usage windows with percentage bars,
* reset timers, and pace indicators.
*/
export function UsageIndicator({ isOpen, onClose, projectId }: UsageIndicatorProps) {
export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: UsageIndicatorProps) {
const { providers, loading, error, lastUpdated, refresh } = useUsageData({
autoRefresh: isOpen, // Only poll when modal is open
});
const [isRefreshing, setIsRefreshing] = useState(false);
const [isDesktopViewport, setIsDesktopViewport] = useState(() =>
typeof window !== "undefined" ? window.innerWidth >= 769 : false
);
const [viewMode, setViewMode] = useState<'used' | 'remaining'>('used');
const [hiddenWindows, setHiddenWindowsState] = useState<Record<string, string[]>>(() =>
getHiddenWindows(projectId)
@@ -456,6 +462,19 @@ export function UsageIndicator({ isOpen, onClose, projectId }: UsageIndicatorPro
}
}, [providers.length]);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const handleResize = () => {
setIsDesktopViewport(window.innerWidth >= 769);
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
// Trigger refresh when modal opens (isOpen transitions from false to true)
useEffect(() => {
// Only refresh when transitioning from closed to open
@@ -560,9 +579,30 @@ export function UsageIndicator({ isOpen, onClose, projectId }: UsageIndicatorPro
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">
const showDesktopPopover = Boolean(anchorRect && isDesktopViewport);
const desktopGap = 8;
const maxTopPadding = 12;
const desktopTop = showDesktopPopover
? Math.min((anchorRect?.bottom ?? 0) + desktopGap, window.innerHeight - maxTopPadding)
: undefined;
const desktopRight = showDesktopPopover
? Math.max(window.innerWidth - (anchorRect?.right ?? 0), 0)
: undefined;
const usageContent = (
<div
className={`usage-modal${showDesktopPopover ? " usage-modal--popover" : " modal"}`}
data-testid="usage-modal"
style={
showDesktopPopover
? ({
position: "fixed",
top: desktopTop,
right: desktopRight,
} as CSSProperties)
: undefined
}
>
<div className="modal-header">
<div className="usage-header">
<Activity size={18} className="usage-header-icon" />
@@ -656,6 +696,24 @@ export function UsageIndicator({ isOpen, onClose, projectId }: UsageIndicatorPro
</div>
</div>
</div>
);
if (showDesktopPopover) {
return (
<>
<div
className="usage-popover-backdrop"
onClick={onClose}
data-testid="usage-modal-overlay"
/>
{usageContent}
</>
);
}
return (
<div className="modal-overlay open" onClick={handleOverlayClick} data-testid="usage-modal-overlay">
{usageContent}
</div>
);
}

View File

@@ -298,19 +298,49 @@ describe("Header", () => {
expect(screen.getByTestId("overflow-usage-btn")).toBeDefined();
});
it("calls onOpenUsage when usage button is clicked on desktop", () => {
it("calls onOpenUsage with button bounds when usage button is clicked on desktop", () => {
const onOpenUsage = vi.fn();
renderHeader({ onOpenUsage }, "desktop");
fireEvent.click(screen.getByTestId("desktop-header-usage-btn"));
expect(onOpenUsage).toHaveBeenCalled();
const usageButton = screen.getByTestId("desktop-header-usage-btn") as HTMLButtonElement;
const mockRect = {
top: 12,
bottom: 52,
left: 820,
right: 860,
width: 40,
height: 40,
x: 820,
y: 12,
toJSON: () => ({}),
} as DOMRect;
usageButton.getBoundingClientRect = vi.fn(() => mockRect);
fireEvent.click(usageButton);
expect(onOpenUsage).toHaveBeenCalledWith(mockRect);
});
it("calls onOpenUsage when usage button in overflow menu is clicked", () => {
it("calls onOpenUsage with button bounds when usage button in overflow menu is clicked", () => {
const onOpenUsage = vi.fn();
renderHeader({ onOpenUsage }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-usage-btn"));
expect(onOpenUsage).toHaveBeenCalled();
const usageButton = screen.getByTestId("overflow-usage-btn") as HTMLButtonElement;
const mockRect = {
top: 100,
bottom: 132,
left: 20,
right: 180,
width: 160,
height: 32,
x: 20,
y: 100,
toJSON: () => ({}),
} as DOMRect;
usageButton.getBoundingClientRect = vi.fn(() => mockRect);
fireEvent.click(usageButton);
expect(onOpenUsage).toHaveBeenCalledWith(mockRect);
});
});

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { UsageIndicator } from "../UsageIndicator";
import "../PlanningModeModal.css";
import "../UsageIndicator.css";
import * as useUsageDataModule from "../../hooks/useUsageData";
import type { ProviderUsage } from "../../api";
import { scopedKey } from "../../utils/projectStorage";
@@ -13,6 +13,21 @@ vi.mock("../../hooks/useUsageData", () => ({
const mockUseUsageData = vi.mocked(useUsageDataModule.useUsageData);
const TEST_PROJECT_ID = "proj-123";
function createAnchorRect(partial: Partial<DOMRect> = {}): DOMRect {
return {
x: 900,
y: 50,
top: 50,
bottom: 80,
left: 900,
right: 940,
width: 40,
height: 30,
toJSON: () => ({}),
...partial,
} as DOMRect;
}
const USAGE_VIEW_MODE_KEY = scopedKey("kb-usage-view-mode", TEST_PROJECT_ID);
const USAGE_HIDDEN_WINDOWS_KEY = scopedKey("kb-usage-hidden-windows", TEST_PROJECT_ID);
@@ -95,10 +110,18 @@ describe("UsageIndicator", () => {
refresh: mockRefresh,
});
render(<UsageIndicator isOpen={true} onClose={mockOnClose} projectId={TEST_PROJECT_ID} />);
render(
<UsageIndicator
isOpen={true}
onClose={mockOnClose}
projectId={TEST_PROJECT_ID}
anchorRect={createAnchorRect()}
/>
);
expect(screen.getByTestId("usage-modal")).toBeInTheDocument();
expect(screen.getByText("Usage")).toBeInTheDocument();
expect(screen.getByTestId("usage-modal")).toHaveClass("usage-modal--popover");
});
it("renders provider cards with correct data", () => {
@@ -208,6 +231,45 @@ describe("UsageIndicator", () => {
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it("renders as popover below anchor on desktop when anchorRect provided", () => {
mockUseUsageData.mockReturnValue({
providers: mockProviders,
loading: false,
error: null,
lastUpdated: new Date(),
refresh: mockRefresh,
});
render(
<UsageIndicator
isOpen={true}
onClose={mockOnClose}
projectId={TEST_PROJECT_ID}
anchorRect={createAnchorRect()}
/>
);
const modal = screen.getByTestId("usage-modal") as HTMLElement;
expect(modal).toHaveClass("usage-modal--popover");
expect(modal.style.top).toBe("88px");
expect(modal.style.right).toBe("84px");
});
it("renders as full-screen modal when anchorRect is null", () => {
mockUseUsageData.mockReturnValue({
providers: mockProviders,
loading: false,
error: null,
lastUpdated: new Date(),
refresh: mockRefresh,
});
render(<UsageIndicator isOpen={true} onClose={mockOnClose} projectId={TEST_PROJECT_ID} anchorRect={null} />);
expect(screen.getByTestId("usage-modal")).toHaveClass("modal");
expect(screen.getByTestId("usage-modal")).not.toHaveClass("usage-modal--popover");
});
it("calls onClose when overlay is clicked", () => {
mockUseUsageData.mockReturnValue({
providers: mockProviders,
@@ -225,6 +287,28 @@ describe("UsageIndicator", () => {
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it("calls onClose when desktop popover backdrop is clicked", () => {
mockUseUsageData.mockReturnValue({
providers: mockProviders,
loading: false,
error: null,
lastUpdated: new Date(),
refresh: mockRefresh,
});
render(
<UsageIndicator
isOpen={true}
onClose={mockOnClose}
projectId={TEST_PROJECT_ID}
anchorRect={createAnchorRect()}
/>
);
fireEvent.click(screen.getByTestId("usage-modal-overlay"));
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it("calls onClose when Escape key is pressed", () => {
mockUseUsageData.mockReturnValue({
providers: mockProviders,

View File

@@ -36,6 +36,7 @@ export interface ModalManager {
schedulesOpen: boolean;
githubImportOpen: boolean;
usageOpen: boolean;
usageAnchorRect: DOMRect | null;
terminalOpen: boolean;
terminalInitialCommand: string | undefined;
filesOpen: boolean;
@@ -77,7 +78,7 @@ export interface ModalManager {
openGitHubImport: () => void;
closeGitHubImport: () => void;
openUsage: () => void;
openUsage: (anchorRect?: DOMRect | null) => void;
closeUsage: () => void;
toggleTerminal: () => void;
@@ -138,6 +139,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
const [schedulesOpen, setSchedulesOpen] = useState(false);
const [githubImportOpen, setGitHubImportOpen] = useState(false);
const [usageOpen, setUsageOpen] = useState(false);
const [usageAnchorRect, setUsageAnchorRect] = useState<DOMRect | null>(null);
const [terminalOpen, setTerminalOpen] = useState(false);
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
const [filesOpen, setFilesOpen] = useState(false);
@@ -238,8 +240,14 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
const openGitHubImport = useCallback(() => setGitHubImportOpen(true), []);
const closeGitHubImport = useCallback(() => setGitHubImportOpen(false), []);
const openUsage = useCallback(() => setUsageOpen(true), []);
const closeUsage = useCallback(() => setUsageOpen(false), []);
const openUsage = useCallback((anchorRect?: DOMRect | null) => {
setUsageAnchorRect(anchorRect ?? null);
setUsageOpen(true);
}, []);
const closeUsage = useCallback(() => {
setUsageOpen(false);
setUsageAnchorRect(null);
}, []);
const toggleTerminal = useCallback(() => {
setTerminalOpen((prev) => !prev);
@@ -316,6 +324,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
schedulesOpen,
githubImportOpen,
usageOpen,
usageAnchorRect,
terminalOpen,
terminalInitialCommand,
filesOpen,