Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .impeccable/live/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"files": ["frontend/index.html"],
"insertBefore": "</body>",
"commentSyntax": "html",
"cspChecked": true
}
47 changes: 47 additions & 0 deletions PRODUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Product

## Register

product

## Users

agentsview is for developers who run multiple AI coding agents and need to
inspect, search, compare, and audit their local session history. They are
usually in a debugging, review, or cost-monitoring workflow where dense,
trustworthy data is more useful than promotional framing.

## Product Purpose

agentsview syncs agent session files into a local archive and serves a fast web
UI for browsing sessions, searching transcripts, tracking usage, and reviewing
costs across projects, models, and agents. Success means users can understand
what happened across their agent runs without sending data to an external
account or re-parsing raw files by hand.

## Brand Personality

Local-first, technical, focused. The interface should feel like an operational
tool: direct, compact, and calm enough for repeated daily use.

## Anti-references

Avoid marketing-site hero patterns, decorative SaaS dashboards, vague
assistant-themed illustrations, and visual treatments that make local developer
data feel like a hosted analytics product. Avoid hiding density behind oversized
cards or ornamental motion.

## Design Principles

- Put the session data first.
- Preserve local-first trust.
- Keep repeated workflows compact and predictable.
- Make freshness, filtering, and state legible.
- Prefer familiar product affordances over novelty.

## Accessibility & Inclusion

No project-specific accessibility profile is documented yet. Default to clear
focus states, keyboard-reachable controls, readable contrast in light and dark
themes, reduced-motion-safe transitions, and chart labels that remain visible
across supported viewport sizes.
5 changes: 3 additions & 2 deletions frontend/src/lib/components/usage/CostTimeSeriesChart.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
const CHART_H = 180;
const X_LABEL_H = 20;
const Y_LABEL_W = 40;
const X_LABEL_RIGHT_PAD = 24;
// Reserved headroom at the top of the plot area so the
// maximum bar, its grid line, and the top y-axis label's
// ascenders do not clip against the SVG viewBox edge.
Expand Down Expand Up @@ -137,7 +138,7 @@
});

const chartWidth = $derived(
Math.max(containerWidth - Y_LABEL_W - 8, 100),
Math.max(containerWidth - Y_LABEL_W - X_LABEL_RIGHT_PAD, 100),
);

const BAR_WIDTH = 40;
Expand Down Expand Up @@ -367,7 +368,7 @@
<svg
width="100%"
height={CHART_H + X_LABEL_H}
viewBox="0 0 {chartWidth + Y_LABEL_W + 8} {CHART_H + X_LABEL_H}"
viewBox="0 0 {chartWidth + Y_LABEL_W + X_LABEL_RIGHT_PAD} {CHART_H + X_LABEL_H}"
preserveAspectRatio="xMidYMid meet"
class="chart-svg"
>
Expand Down
156 changes: 156 additions & 0 deletions frontend/src/lib/components/usage/CostTimeSeriesChart.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// @vitest-environment jsdom
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "vitest";
import { mount, tick, unmount } from "svelte";
// @ts-ignore
import CostTimeSeriesChart from "./CostTimeSeriesChart.svelte";
import { usage } from "../../stores/usage.svelte.js";
import type {
DailyUsageEntry,
UsageSummaryResponse,
} from "../../api/types/usage.js";

const OBSERVED_WIDTH = 1648;

class ImmediateResizeObserver implements ResizeObserver {
private readonly callback: ResizeObserverCallback;

constructor(callback: ResizeObserverCallback) {
this.callback = callback;
}

observe(target: Element): void {
this.callback(
[
{
target,
contentRect: {
width: OBSERVED_WIDTH,
height: 200,
x: 0,
y: 0,
top: 0,
right: OBSERVED_WIDTH,
bottom: 200,
left: 0,
toJSON: () => ({}),
},
} as ResizeObserverEntry,
],
this,
);
}

unobserve(): void {}
disconnect(): void {}
}

function dailyEntry(index: number): DailyUsageEntry {
const date = new Date("2026-06-04T00:00:00");
date.setDate(date.getDate() + index);
const isoDate = date.toISOString().slice(0, 10);

return {
date: isoDate,
inputTokens: 100,
outputTokens: 50,
cacheCreationTokens: 0,
cacheReadTokens: 0,
totalCost: 10,
modelsUsed: ["model"],
projectBreakdowns: [
{
project: "agentsview",
inputTokens: 100,
outputTokens: 50,
cacheCreationTokens: 0,
cacheReadTokens: 0,
cost: 10,
},
],
};
}

function usageSummary(): UsageSummaryResponse {
return {
from: "2026-06-04",
to: "2026-06-18",
totals: {
inputTokens: 1500,
outputTokens: 750,
cacheCreationTokens: 0,
cacheReadTokens: 0,
totalCost: 150,
},
daily: Array.from({ length: 15 }, (_, i) => dailyEntry(i)),
projectTotals: [
{
project: "agentsview",
inputTokens: 1500,
outputTokens: 750,
cacheCreationTokens: 0,
cacheReadTokens: 0,
cost: 150,
},
],
modelTotals: [],
agentTotals: [],
sessionCounts: {
total: 15,
byProject: { agentsview: 15 },
byAgent: {},
},
cacheStats: {
cacheReadTokens: 0,
cacheCreationTokens: 0,
uncachedInputTokens: 1500,
outputTokens: 750,
hitRate: 0,
savingsVsUncached: 0,
},
};
}

describe("CostTimeSeriesChart", () => {
beforeEach(() => {
globalThis.ResizeObserver =
ImmediateResizeObserver as typeof ResizeObserver;
usage.summary = usageSummary();
usage.toggles.timeSeries.groupBy = "project";
});

afterEach(() => {
usage.summary = null;
document.body.innerHTML = "";
});

it("keeps the rightmost date label inside the SVG viewBox", async () => {
const component = mount(CostTimeSeriesChart, {
target: document.body,
});
await tick();

const svg = document.querySelector("svg.chart-svg");
expect(svg).toBeTruthy();
const viewBox = svg!.getAttribute("viewBox")!.split(" ").map(Number);
const viewBoxRight = viewBox[2]!;

const labels = Array.from(
document.querySelectorAll<SVGTextElement>("text.x-label"),
);
const lastLabel = labels.at(-1);
expect(lastLabel).toBeTruthy();

const x = Number(lastLabel!.getAttribute("x"));
const textWidthEstimate = lastLabel!.textContent!.length * 5;

expect(x + textWidthEstimate / 2).toBeLessThanOrEqual(viewBoxRight);

unmount(component);
});
});