Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion src-tauri/src/local_usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ fn build_snapshot(
let last7_tokens: i64 = last7.iter().map(|day| day.total_tokens).sum();
let last7_input: i64 = last7.iter().map(|day| day.input_tokens).sum();
let last7_cached: i64 = last7.iter().map(|day| day.cached_input_tokens).sum();
let last30_tokens: i64 = days
.iter()
.rev()
.take(30)
.map(|day| day.total_tokens)
.sum();

let average_daily_tokens = if last7.is_empty() {
0
Expand Down Expand Up @@ -171,7 +177,7 @@ fn build_snapshot(
days,
totals: LocalUsageTotals {
last7_days_tokens: last7_tokens,
last30_days_tokens: total_tokens,
last30_days_tokens: last30_tokens,
average_daily_tokens,
cache_hit_rate_percent,
peak_day,
Expand Down Expand Up @@ -803,6 +809,36 @@ mod tests {
assert_eq!(snapshot.totals.last30_days_tokens, 11);
}

#[test]
fn build_snapshot_uses_last_30_days_tokens_only() {
let day_keys = make_day_keys(40);
let mut daily: HashMap<String, DailyTotals> = HashMap::new();
for (index, day_key) in day_keys.iter().enumerate() {
let input_tokens = (index as i64) + 1;
daily.insert(
day_key.clone(),
DailyTotals {
input: input_tokens,
cached: 0,
output: 0,
agent_ms: 0,
agent_runs: 0,
},
);
}

let snapshot = build_snapshot(0, day_keys.clone(), daily, HashMap::new());
let expected_last30: i64 = day_keys
.iter()
.rev()
.take(30)
.enumerate()
.map(|(offset, _)| (40 - offset) as i64)
.sum();

assert_eq!(snapshot.totals.last30_days_tokens, expected_last30);
}

#[test]
fn resolve_sessions_roots_includes_workspace_overrides() {
let mut workspaces = HashMap::new();
Expand Down
12 changes: 12 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import "./styles/base.css";
import "./styles/buttons.css";
import "./styles/sidebar.css";
import "./styles/home.css";
import "./styles/usage-details-modal.css";
import "./styles/workspace-home.css";
import "./styles/main.css";
import "./styles/messages.css";
Expand Down Expand Up @@ -78,6 +79,7 @@ import { useWorkspaceSelection } from "./features/workspaces/hooks/useWorkspaceS
import { useLocalUsage } from "./features/home/hooks/useLocalUsage";
import { useGitHubPanelController } from "./features/app/hooks/useGitHubPanelController";
import { useSettingsModalState } from "./features/app/hooks/useSettingsModalState";
import { useUsageDetailsModalState } from "./features/app/hooks/useUsageDetailsModalState";
import { usePersistComposerSettings } from "./features/app/hooks/usePersistComposerSettings";
import { useSyncSelectedDiffPath } from "./features/app/hooks/useSyncSelectedDiffPath";
import { useMenuAcceleratorController } from "./features/app/hooks/useMenuAcceleratorController";
Expand Down Expand Up @@ -252,6 +254,11 @@ function MainApp() {
openSettings,
closeSettings,
} = useSettingsModalState();
const {
usageDetailsOpen,
openUsageDetails,
closeUsageDetails,
} = useUsageDetailsModalState();
const composerInputRef = useRef<HTMLTextAreaElement | null>(null);

const {
Expand Down Expand Up @@ -1687,6 +1694,7 @@ function MainApp() {
usageWorkspaceId,
usageWorkspaceOptions,
onUsageWorkspaceChange: setUsageWorkspaceId,
onOpenUsageDetails: openUsageDetails,
onSelectHomeThread: (workspaceId, threadId) => {
exitDiffView();
selectWorkspace(workspaceId);
Expand Down Expand Up @@ -2146,6 +2154,10 @@ function MainApp() {
onCancelDictationDownload: dictationModel.cancel,
onRemoveDictationModel: dictationModel.remove,
}}
usageDetailsOpen={usageDetailsOpen}
usageDays={localUsageSnapshot?.days ?? []}
usageMetric={usageMetric}
onCloseUsageDetails={closeUsageDetails}
/>
</div>
);
Expand Down
17 changes: 17 additions & 0 deletions src/features/app/components/AppModals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { SettingsViewProps } from "../../settings/components/SettingsView";
import { useRenameThreadPrompt } from "../../threads/hooks/useRenameThreadPrompt";
import { useClonePrompt } from "../../workspaces/hooks/useClonePrompt";
import { useWorktreePrompt } from "../../workspaces/hooks/useWorktreePrompt";
import { UsageDetailsModal } from "../../home/components/UsageDetailsModal";

const RenameThreadPrompt = lazy(() =>
import("../../threads/components/RenameThreadPrompt").then((module) => ({
Expand Down Expand Up @@ -49,6 +50,10 @@ type AppModalsProps = {
onCloseSettings: () => void;
SettingsViewComponent: ComponentType<SettingsViewProps>;
settingsProps: Omit<SettingsViewProps, "initialSection" | "onClose">;
usageDetailsOpen: boolean;
usageDays: { day: string; totalTokens: number; agentTimeMs?: number | null }[];
usageMetric: "tokens" | "time";
onCloseUsageDetails: () => void;
};

export const AppModals = memo(function AppModals({
Expand All @@ -73,6 +78,10 @@ export const AppModals = memo(function AppModals({
onCloseSettings,
SettingsViewComponent,
settingsProps,
usageDetailsOpen,
usageDays,
usageMetric,
onCloseUsageDetails,
}: AppModalsProps) {
return (
<>
Expand Down Expand Up @@ -131,6 +140,14 @@ export const AppModals = memo(function AppModals({
/>
</Suspense>
)}
{usageDetailsOpen && (
<UsageDetailsModal
isOpen={usageDetailsOpen}
days={usageDays}
usageMetric={usageMetric}
onClose={onCloseUsageDetails}
/>
)}
</>
);
});
20 changes: 20 additions & 0 deletions src/features/app/hooks/useUsageDetailsModalState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useCallback, useState } from "react";

export function useUsageDetailsModalState() {
const [usageDetailsOpen, setUsageDetailsOpen] = useState(false);

const openUsageDetails = useCallback(() => {
setUsageDetailsOpen(true);
}, []);

const closeUsageDetails = useCallback(() => {
setUsageDetailsOpen(false);
}, []);

return {
usageDetailsOpen,
openUsageDetails,
closeUsageDetails,
setUsageDetailsOpen,
};
}
48 changes: 46 additions & 2 deletions src/features/home/components/Home.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Home } from "./Home";

const baseProps = {
Expand All @@ -18,8 +18,13 @@ const baseProps = {
usageWorkspaceOptions: [],
onUsageWorkspaceChange: vi.fn(),
onSelectThread: vi.fn(),
onOpenUsageDetails: vi.fn(),
};

afterEach(() => {
cleanup();
});

describe("Home", () => {
it("renders latest agent runs and lets you open a thread", () => {
const onSelectThread = vi.fn();
Expand Down Expand Up @@ -99,4 +104,43 @@ describe("Home", () => {
expect(screen.getByText("Runs")).toBeTruthy();
expect(screen.getByText("Peak day")).toBeTruthy();
});

it("opens the usage details modal when clicking More", () => {
const onOpenUsageDetails = vi.fn();
render(
<Home
{...baseProps}
onOpenUsageDetails={onOpenUsageDetails}
localUsageSnapshot={{
updatedAt: Date.now(),
days: [
{
day: "2026-01-20",
inputTokens: 10,
cachedInputTokens: 0,
outputTokens: 5,
totalTokens: 15,
agentTimeMs: 120000,
agentRuns: 2,
},
],
totals: {
last7DaysTokens: 15,
last30DaysTokens: 15,
averageDailyTokens: 15,
cacheHitRatePercent: 0,
peakDay: "2026-01-20",
peakDayTokens: 15,
},
topModels: [],
}}
/>,
);

const [moreButton] = screen.getAllByRole("button", {
name: "More usage details",
});
fireEvent.click(moreButton);
expect(onOpenUsageDetails).toHaveBeenCalledTimes(1);
});
});
10 changes: 10 additions & 0 deletions src/features/home/components/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type HomeProps = {
usageWorkspaceOptions: UsageWorkspaceOption[];
onUsageWorkspaceChange: (workspaceId: string | null) => void;
onSelectThread: (workspaceId: string, threadId: string) => void;
onOpenUsageDetails: () => void;
};

export function Home({
Expand All @@ -51,6 +52,7 @@ export function Home({
usageWorkspaceOptions,
onUsageWorkspaceChange,
onSelectThread,
onOpenUsageDetails,
}: HomeProps) {
const formatCompactNumber = (value: number | null | undefined) => {
if (value === null || value === undefined) {
Expand Down Expand Up @@ -464,6 +466,14 @@ export function Home({
)}
</div>
<div className="home-usage-chart-card">
<button
type="button"
className="home-usage-more"
onClick={onOpenUsageDetails}
aria-label="More usage details"
>
More
</button>
<div className="home-usage-chart">
{last7Days.map((day) => {
const value =
Expand Down
63 changes: 63 additions & 0 deletions src/features/home/components/UsageDetailsModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { UsageDetailsModal } from "./UsageDetailsModal";

const baseProps = {
isOpen: true,
days: [],
usageMetric: "tokens" as const,
onClose: vi.fn(),
};

afterEach(() => {
cleanup();
});

describe("UsageDetailsModal", () => {
it("renders empty state when no data is available", () => {
render(<UsageDetailsModal {...baseProps} />);

expect(screen.getByText("Usage details")).toBeTruthy();
expect(screen.getByText("Select a range")).toBeTruthy();
expect(screen.getByText("No usage data for this range.")).toBeTruthy();
});

it("calls onClose when clicking the close button", () => {
const onClose = vi.fn();
render(<UsageDetailsModal {...baseProps} onClose={onClose} />);

const [closeButton] = screen.getAllByRole("button", {
name: "Close usage details",
});
fireEvent.click(closeButton);
expect(onClose).toHaveBeenCalledTimes(1);
});

it("calls onClose on Escape", () => {
const onClose = vi.fn();
render(<UsageDetailsModal {...baseProps} onClose={onClose} />);

window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
expect(onClose).toHaveBeenCalledTimes(1);
});

it("renders usage bars for the active range", async () => {
render(
<UsageDetailsModal
{...baseProps}
days={[
{
day: "2026-01-20",
totalTokens: 1200,
agentTimeMs: 90000,
agentRuns: 2,
},
]}
/>,
);

const items = await screen.findAllByRole("listitem");
expect(items).toHaveLength(1);
});
});
Loading