feat(timeline): dual-handle range selection on Usage Over Time chart

- Dual drag handles on SVG chart for time range selection
- Bars outside range dimmed, stats + conversation filtered to range
- Slot-based bar sizing prevents overflow at any point count
- Handle-only drag zones with col-resize cursor
- Reset button to clear selection
- computeFilteredUsage() helper with 8 unit tests
- Named constants, CSS classes instead of inline styles
This commit is contained in:
Jonathan Gelin
2026-02-16 11:55:26 +01:00
committed by Peter Steinberger
parent 86517b8e30
commit 0302cf89b0
9 changed files with 631 additions and 92 deletions

View File

@@ -37,6 +37,8 @@ export function renderUsageTab(state: AppViewState) {
timeSeriesBreakdownMode: state.usageTimeSeriesBreakdownMode,
timeSeries: state.usageTimeSeries,
timeSeriesLoading: state.usageTimeSeriesLoading,
timeSeriesCursorStart: state.usageTimeSeriesCursorStart,
timeSeriesCursorEnd: state.usageTimeSeriesCursorEnd,
sessionLogs: state.usageSessionLogs,
sessionLogsLoading: state.usageSessionLogsLoading,
sessionLogsExpanded: state.usageSessionLogsExpanded,
@@ -196,6 +198,10 @@ export function renderUsageTab(state: AppViewState) {
}
}
// Reset range selection when switching sessions
state.usageTimeSeriesCursorStart = null;
state.usageTimeSeriesCursorEnd = null;
// Load timeseries/logs only if exactly one session selected
if (state.usageSelectedSessions.length === 1) {
void loadSessionTimeSeries(state, state.usageSelectedSessions[0]);
@@ -237,6 +243,10 @@ export function renderUsageTab(state: AppViewState) {
onTimeSeriesBreakdownChange: (mode) => {
state.usageTimeSeriesBreakdownMode = mode;
},
onTimeSeriesCursorRangeChange: (start, end) => {
state.usageTimeSeriesCursorStart = start;
state.usageTimeSeriesCursorEnd = end;
},
onClearDays: () => {
state.usageSelectedDays = [];
},

View File

@@ -164,6 +164,8 @@ export type AppViewState = {
usageTimeSeriesBreakdownMode: "total" | "by-type";
usageTimeSeries: SessionUsageTimeSeries | null;
usageTimeSeriesLoading: boolean;
usageTimeSeriesCursorStart: number | null;
usageTimeSeriesCursorEnd: number | null;
usageSessionLogs: SessionLogEntry[] | null;
usageSessionLogsLoading: boolean;
usageSessionLogsExpanded: boolean;

View File

@@ -260,6 +260,8 @@ export class OpenClawApp extends LitElement {
@state() usageTimeSeriesBreakdownMode: "total" | "by-type" = "by-type";
@state() usageTimeSeries: import("./types.js").SessionUsageTimeSeries | null = null;
@state() usageTimeSeriesLoading = false;
@state() usageTimeSeriesCursorStart: number | null = null;
@state() usageTimeSeriesCursorEnd: number | null = null;
@state() usageSessionLogs: import("./views/usage.js").SessionLogEntry[] | null = null;
@state() usageSessionLogsLoading = false;
@state() usageSessionLogsExpanded = false;

View File

@@ -15,6 +15,8 @@ export type UsageState = {
usageSelectedDays: string[];
usageTimeSeries: SessionUsageTimeSeries | null;
usageTimeSeriesLoading: boolean;
usageTimeSeriesCursorStart: number | null;
usageTimeSeriesCursorEnd: number | null;
usageSessionLogs: SessionLogEntry[] | null;
usageSessionLogsLoading: boolean;
};
@@ -94,7 +96,10 @@ export async function loadSessionLogs(state: UsageState, sessionKey: string) {
state.usageSessionLogsLoading = true;
state.usageSessionLogs = null;
try {
const res = await state.client.request("sessions.usage.logs", { key: sessionKey, limit: 500 });
const res = await state.client.request("sessions.usage.logs", {
key: sessionKey,
limit: 2000,
});
if (res && Array.isArray((res as { logs: SessionLogEntry[] }).logs)) {
state.usageSessionLogs = (res as { logs: SessionLogEntry[] }).logs;
}

View File

@@ -0,0 +1,132 @@
import { describe, it, expect } from "vitest";
import type { TimeSeriesPoint, UsageSessionEntry } from "./usageTypes.ts";
import {
computeFilteredUsage,
CHART_BAR_WIDTH_RATIO,
CHART_MAX_BAR_WIDTH,
} from "./usage-render-details.ts";
function makePoint(overrides: Partial<TimeSeriesPoint> = {}): TimeSeriesPoint {
return {
timestamp: 1000,
totalTokens: 100,
cost: 0.01,
input: 30,
output: 40,
cacheRead: 20,
cacheWrite: 10,
cumulativeTokens: 0,
cumulativeCost: 0,
...overrides,
};
}
const baseUsage = {
totalTokens: 1000,
totalCost: 1.0,
input: 300,
output: 400,
cacheRead: 200,
cacheWrite: 100,
durationMs: 60000,
firstActivity: 0,
lastActivity: 60000,
missingCostEntries: 0,
messageCounts: {
total: 10,
user: 5,
assistant: 5,
toolCalls: 0,
toolResults: 0,
errors: 0,
},
} satisfies NonNullable<UsageSessionEntry["usage"]>;
describe("computeFilteredUsage", () => {
it("returns undefined when no points match the range", () => {
const points = [makePoint({ timestamp: 1000 }), makePoint({ timestamp: 2000 })];
const result = computeFilteredUsage(baseUsage, points, 3000, 4000);
expect(result).toBeUndefined();
});
it("aggregates tokens and cost for points within range", () => {
const points = [
makePoint({ timestamp: 1000, totalTokens: 100, cost: 0.1 }),
makePoint({ timestamp: 2000, totalTokens: 200, cost: 0.2 }),
makePoint({ timestamp: 3000, totalTokens: 300, cost: 0.3 }),
];
const result = computeFilteredUsage(baseUsage, points, 1000, 2000);
expect(result).toBeDefined();
expect(result!.totalTokens).toBe(300); // 100 + 200
expect(result!.totalCost).toBeCloseTo(0.3); // 0.1 + 0.2
});
it("handles reversed range (end < start)", () => {
const points = [
makePoint({ timestamp: 1000, totalTokens: 50 }),
makePoint({ timestamp: 2000, totalTokens: 75 }),
];
const result = computeFilteredUsage(baseUsage, points, 2000, 1000);
expect(result).toBeDefined();
expect(result!.totalTokens).toBe(125);
});
it("counts message types based on input/output presence", () => {
const points = [
makePoint({ timestamp: 1000, input: 10, output: 0 }),
makePoint({ timestamp: 2000, input: 0, output: 20 }),
makePoint({ timestamp: 3000, input: 5, output: 15 }),
];
const result = computeFilteredUsage(baseUsage, points, 1000, 3000);
expect(result!.messageCounts!.user).toBe(2); // points with input > 0
expect(result!.messageCounts!.assistant).toBe(2); // points with output > 0
expect(result!.messageCounts!.total).toBe(3);
});
it("computes duration from first to last filtered point", () => {
const points = [makePoint({ timestamp: 1000 }), makePoint({ timestamp: 5000 })];
const result = computeFilteredUsage(baseUsage, points, 1000, 5000);
expect(result!.durationMs).toBe(4000);
expect(result!.firstActivity).toBe(1000);
expect(result!.lastActivity).toBe(5000);
});
it("aggregates token types (input, output, cacheRead, cacheWrite)", () => {
const points = [
makePoint({ timestamp: 1000, input: 10, output: 20, cacheRead: 30, cacheWrite: 40 }),
makePoint({ timestamp: 2000, input: 5, output: 15, cacheRead: 25, cacheWrite: 35 }),
];
const result = computeFilteredUsage(baseUsage, points, 1000, 2000);
expect(result!.input).toBe(15);
expect(result!.output).toBe(35);
expect(result!.cacheRead).toBe(55);
expect(result!.cacheWrite).toBe(75);
});
});
describe("chart bar sizing", () => {
it("bar width ratio and max are reasonable", () => {
expect(CHART_BAR_WIDTH_RATIO).toBeGreaterThan(0);
expect(CHART_BAR_WIDTH_RATIO).toBeLessThan(1);
expect(CHART_MAX_BAR_WIDTH).toBeGreaterThan(0);
});
it("bars fit within chart width for typical point counts", () => {
const chartWidth = 366; // typical: 400 - padding.left(30) - padding.right(4)
// For reasonable point counts (up to ~300), bars should fit
for (const n of [1, 2, 10, 50, 100, 200]) {
const slotWidth = chartWidth / n;
const barWidth = Math.min(
CHART_MAX_BAR_WIDTH,
Math.max(1, slotWidth * CHART_BAR_WIDTH_RATIO),
);
const barGap = slotWidth - barWidth;
// Slot-based sizing guarantees total = n * slotWidth = chartWidth
expect(n * slotWidth).toBeCloseTo(chartWidth);
// Bar gap is non-negative when slotWidth >= 1 / CHART_BAR_WIDTH_RATIO
if (slotWidth >= 1 / CHART_BAR_WIDTH_RATIO) {
expect(barGap).toBeGreaterThanOrEqual(0);
}
}
});
});

View File

@@ -10,6 +10,14 @@ import {
UsageSessionEntry,
} from "./usageTypes.ts";
// Chart constants
const CHART_BAR_WIDTH_RATIO = 0.75; // Fraction of slot used for bar (rest is gap)
const CHART_MAX_BAR_WIDTH = 8; // Max bar width in SVG viewBox units
const CHART_SELECTION_OPACITY = 0.06; // Opacity of range selection overlay
const HANDLE_WIDTH = 5; // Width of drag handle in SVG units
const HANDLE_HEIGHT = 12; // Height of drag handle
const HANDLE_GRIP_OFFSET = 0.7; // Offset of grip lines inside handle
function pct(part: number, total: number): number {
if (!total || total <= 0) {
return 0;
@@ -21,8 +29,34 @@ function renderEmptyDetailState() {
return nothing;
}
function renderSessionSummary(session: UsageSessionEntry) {
const usage = session.usage;
/** Normalize a log timestamp to milliseconds (handles seconds vs ms). */
function normalizeLogTimestamp(ts: number): number {
return ts < 1e12 ? ts * 1000 : ts;
}
/** Filter session logs by a timestamp range. */
function filterLogsByRange(
logs: SessionLogEntry[],
rangeStart: number,
rangeEnd: number,
): SessionLogEntry[] {
const lo = Math.min(rangeStart, rangeEnd);
const hi = Math.max(rangeStart, rangeEnd);
return logs.filter((log) => {
if (log.timestamp <= 0) {
return true;
}
const ts = normalizeLogTimestamp(log.timestamp);
return ts >= lo && ts <= hi;
});
}
function renderSessionSummary(
session: UsageSessionEntry,
filteredUsage?: UsageSessionEntry["usage"],
filteredLogs?: SessionLogEntry[],
) {
const usage = filteredUsage || session.usage;
if (!usage) {
return html`
<div class="muted">No usage data for this session.</div>
@@ -45,12 +79,37 @@ function renderSessionSummary(session: UsageSessionEntry) {
badges.push(`model:${session.model}`);
}
const toolItems =
usage.toolUsage?.tools.slice(0, 6).map((tool) => ({
// Always use the full tool list for stable layout; update counts when filtering
const baseTools = usage.toolUsage?.tools.slice(0, 6) ?? [];
let toolCallCount: number;
let uniqueToolCount: number;
let toolItems: Array<{ label: string; value: string; sub: string }>;
if (filteredLogs) {
const toolCounts = new Map<string, number>();
for (const log of filteredLogs) {
const { tools } = parseToolSummary(log.content);
for (const [name] of tools) {
toolCounts.set(name, (toolCounts.get(name) || 0) + 1);
}
}
// Keep the same tool order as the full session, just update counts
toolItems = baseTools.map((tool) => ({
label: tool.name,
value: `${toolCounts.get(tool.name) ?? 0}`,
sub: "calls",
}));
toolCallCount = [...toolCounts.values()].reduce((sum, c) => sum + c, 0);
uniqueToolCount = toolCounts.size;
} else {
toolItems = baseTools.map((tool) => ({
label: tool.name,
value: `${tool.count}`,
sub: "calls",
})) ?? [];
}));
toolCallCount = usage.toolUsage?.totalCalls ?? 0;
uniqueToolCount = usage.toolUsage?.uniqueTools ?? 0;
}
const modelItems =
usage.modelUsage?.slice(0, 6).map((entry) => ({
label: entry.model ?? "unknown",
@@ -68,8 +127,8 @@ function renderSessionSummary(session: UsageSessionEntry) {
</div>
<div class="session-summary-card">
<div class="session-summary-title">Tool Calls</div>
<div class="session-summary-value">${usage.toolUsage?.totalCalls ?? 0}</div>
<div class="session-summary-meta">${usage.toolUsage?.uniqueTools ?? 0} tools</div>
<div class="session-summary-value">${toolCallCount}</div>
<div class="session-summary-meta">${uniqueToolCount} tools</div>
</div>
<div class="session-summary-card">
<div class="session-summary-title">Errors</div>
@@ -89,6 +148,66 @@ function renderSessionSummary(session: UsageSessionEntry) {
`;
}
/** Aggregate usage stats from time series points within a timestamp range. */
function computeFilteredUsage(
baseUsage: NonNullable<UsageSessionEntry["usage"]>,
points: TimeSeriesPoint[],
rangeStart: number,
rangeEnd: number,
): UsageSessionEntry["usage"] | undefined {
const lo = Math.min(rangeStart, rangeEnd);
const hi = Math.max(rangeStart, rangeEnd);
const filtered = points.filter((p) => p.timestamp >= lo && p.timestamp <= hi);
if (filtered.length === 0) {
return undefined;
}
let totalTokens = 0;
let totalCost = 0;
let userMessages = 0;
let assistantMessages = 0;
let totalInput = 0;
let totalOutput = 0;
let totalCacheRead = 0;
let totalCacheWrite = 0;
for (const p of filtered) {
totalTokens += p.totalTokens || 0;
totalCost += p.cost || 0;
totalInput += p.input || 0;
totalOutput += p.output || 0;
totalCacheRead += p.cacheRead || 0;
totalCacheWrite += p.cacheWrite || 0;
if (p.output > 0) {
assistantMessages++;
}
if (p.input > 0) {
userMessages++;
}
}
return {
...baseUsage,
totalTokens,
totalCost,
input: totalInput,
output: totalOutput,
cacheRead: totalCacheRead,
cacheWrite: totalCacheWrite,
durationMs: filtered[filtered.length - 1].timestamp - filtered[0].timestamp,
firstActivity: filtered[0].timestamp,
lastActivity: filtered[filtered.length - 1].timestamp,
messageCounts: {
total: filtered.length,
user: userMessages,
assistant: assistantMessages,
toolCalls: 0,
toolResults: 0,
errors: 0,
},
};
}
function renderSessionDetailPanel(
session: UsageSessionEntry,
timeSeries: { points: TimeSeriesPoint[] } | null,
@@ -97,6 +216,9 @@ function renderSessionDetailPanel(
onTimeSeriesModeChange: (mode: "cumulative" | "per-turn") => void,
timeSeriesBreakdownMode: "total" | "by-type",
onTimeSeriesBreakdownChange: (mode: "total" | "by-type") => void,
timeSeriesCursorStart: number | null,
timeSeriesCursorEnd: number | null,
onTimeSeriesCursorRangeChange: (start: number | null, end: number | null) => void,
startDate: string,
endDate: string,
selectedDays: string[],
@@ -123,18 +245,31 @@ function renderSessionDetailPanel(
const displayLabel = label.length > 50 ? label.slice(0, 50) + "…" : label;
const usage = session.usage;
const hasRange = timeSeriesCursorStart !== null && timeSeriesCursorEnd !== null;
const filteredUsage =
timeSeriesCursorStart !== null && timeSeriesCursorEnd !== null && timeSeries?.points && usage
? computeFilteredUsage(usage, timeSeries.points, timeSeriesCursorStart, timeSeriesCursorEnd)
: undefined;
const headerStats = filteredUsage
? { totalTokens: filteredUsage.totalTokens, totalCost: filteredUsage.totalCost }
: { totalTokens: usage?.totalTokens ?? 0, totalCost: usage?.totalCost ?? 0 };
const cursorIndicator = filteredUsage ? " (filtered)" : "";
return html`
<div class="card session-detail-panel">
<div class="session-detail-header">
<div class="session-detail-header-left">
<div class="session-detail-title">${displayLabel}</div>
<div class="session-detail-title">
${displayLabel}
${cursorIndicator ? html`<span style="font-size: 11px; color: var(--muted); margin-left: 8px;">${cursorIndicator}</span>` : nothing}
</div>
</div>
<div class="session-detail-stats">
${
usage
? html`
<span><strong>${formatTokens(usage.totalTokens)}</strong> tokens</span>
<span><strong>${formatCost(usage.totalCost)}</strong></span>
<span><strong>${formatTokens(headerStats.totalTokens)}</strong> tokens${cursorIndicator}</span>
<span><strong>${formatCost(headerStats.totalCost)}</strong>${cursorIndicator}</span>
`
: nothing
}
@@ -142,7 +277,13 @@ function renderSessionDetailPanel(
<button class="session-close-btn" @click=${onClose} title="Close session details">×</button>
</div>
<div class="session-detail-content">
${renderSessionSummary(session)}
${renderSessionSummary(
session,
filteredUsage,
timeSeriesCursorStart != null && timeSeriesCursorEnd != null && sessionLogs
? filterLogsByRange(sessionLogs, timeSeriesCursorStart, timeSeriesCursorEnd)
: undefined,
)}
<div class="session-detail-row">
${renderTimeSeriesCompact(
timeSeries,
@@ -154,6 +295,9 @@ function renderSessionDetailPanel(
startDate,
endDate,
selectedDays,
timeSeriesCursorStart,
timeSeriesCursorEnd,
onTimeSeriesCursorRangeChange,
)}
</div>
<div class="session-detail-bottom">
@@ -168,6 +312,8 @@ function renderSessionDetailPanel(
onLogFilterHasToolsChange,
onLogFilterQueryChange,
onLogFilterClear,
hasRange ? timeSeriesCursorStart : null,
hasRange ? timeSeriesCursorEnd : null,
)}
${renderContextPanel(session.contextWeight, usage, contextExpanded, onToggleContextExpanded)}
</div>
@@ -186,6 +332,9 @@ function renderTimeSeriesCompact(
startDate?: string,
endDate?: string,
selectedDays?: string[],
cursorStart?: number | null,
cursorEnd?: number | null,
onCursorRangeChange?: (start: number | null, end: number | null) => void,
) {
if (loading) {
return html`
@@ -242,14 +391,44 @@ function renderTimeSeriesCompact(
return { ...p, cumulativeTokens: cumTokens, cumulativeCost: cumCost };
});
// Compute range-filtered sums for "Tokens by Type"
const hasSelection = cursorStart != null && cursorEnd != null;
const rangeStartTs = hasSelection ? Math.min(cursorStart, cursorEnd) : 0;
const rangeEndTs = hasSelection ? Math.max(cursorStart, cursorEnd) : Infinity;
// Find start/end indices for dimming
let rangeStartIdx = 0;
let rangeEndIdx = points.length;
if (hasSelection) {
rangeStartIdx = points.findIndex((p) => p.timestamp >= rangeStartTs);
if (rangeStartIdx === -1) {
rangeStartIdx = points.length;
}
const endIdx = points.findIndex((p) => p.timestamp > rangeEndTs);
rangeEndIdx = endIdx === -1 ? points.length : endIdx;
}
const filteredPoints = hasSelection ? points.slice(rangeStartIdx, rangeEndIdx) : points;
let filteredOutput = 0,
filteredInput = 0,
filteredCacheRead = 0,
filteredCacheWrite = 0;
for (const p of filteredPoints) {
filteredOutput += p.output;
filteredInput += p.input;
filteredCacheRead += p.cacheRead;
filteredCacheWrite += p.cacheWrite;
}
const width = 400,
height = 80;
const padding = { top: 16, right: 10, bottom: 20, left: 40 };
height = 100;
const padding = { top: 8, right: 4, bottom: 14, left: 30 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
const isCumulative = mode === "cumulative";
const breakdownByType = mode === "per-turn" && breakdownMode === "by-type";
const totalTypeTokens = sumOutput + sumInput + sumCacheRead + sumCacheWrite;
const totalTypeTokens = filteredOutput + filteredInput + filteredCacheRead + filteredCacheWrite;
const barTotals = points.map((p) =>
isCumulative
? p.cumulativeTokens
@@ -258,14 +437,32 @@ function renderTimeSeriesCompact(
: p.totalTokens,
);
const maxValue = Math.max(...barTotals, 1);
const barWidth = Math.max(2, Math.min(8, (chartWidth / points.length) * 0.7));
const barGap = Math.max(1, (chartWidth - barWidth * points.length) / (points.length - 1 || 1));
// Ensure bars + gaps fit exactly within chartWidth
const slotWidth = chartWidth / points.length; // space per bar including gap
const barWidth = Math.min(CHART_MAX_BAR_WIDTH, Math.max(1, slotWidth * CHART_BAR_WIDTH_RATIO));
const barGap = slotWidth - barWidth;
// Pre-compute handle X positions in SVG viewBox coordinates
const leftHandleX = padding.left + rangeStartIdx * (barWidth + barGap);
const rightHandleX =
rangeEndIdx >= points.length
? padding.left + (points.length - 1) * (barWidth + barGap) + barWidth // right edge of last bar
: padding.left + (rangeEndIdx - 1) * (barWidth + barGap) + barWidth; // right edge of last selected bar
return html`
<div class="session-timeseries-compact">
<div class="timeseries-header-row">
<div class="card-title" style="font-size: 13px;">Usage Over Time</div>
<div class="card-title" style="font-size: 12px; color: var(--text);">Usage Over Time</div>
<div class="timeseries-controls">
${
hasSelection
? html`
<div class="chart-toggle small">
<button class="toggle-btn active" @click=${() => onCursorRangeChange?.(null, null)}>Reset</button>
</div>
`
: nothing
}
<div class="chart-toggle small">
<button
class="toggle-btn ${!isCumulative ? "active" : ""}"
@@ -302,92 +499,211 @@ function renderTimeSeriesCompact(
}
</div>
</div>
<svg viewBox="0 0 ${width} ${height + 15}" class="timeseries-svg" style="width: 100%; height: auto;">
<!-- Y axis -->
<line x1="${padding.left}" y1="${padding.top}" x2="${padding.left}" y2="${padding.top + chartHeight}" stroke="var(--border)" />
<!-- X axis -->
<line x1="${padding.left}" y1="${padding.top + chartHeight}" x2="${width - padding.right}" y2="${padding.top + chartHeight}" stroke="var(--border)" />
<!-- Y axis labels -->
<text x="${padding.left - 4}" y="${padding.top + 4}" text-anchor="end" class="axis-label" style="font-size: 9px; fill: var(--text-muted)">${formatTokens(maxValue)}</text>
<text x="${padding.left - 4}" y="${padding.top + chartHeight}" text-anchor="end" class="axis-label" style="font-size: 9px; fill: var(--text-muted)">0</text>
<!-- X axis labels (first and last) -->
${
points.length > 0
? svg`
<text x="${padding.left}" y="${padding.top + chartHeight + 12}" text-anchor="start" style="font-size: 8px; fill: var(--text-muted)">${new Date(points[0].timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" })}</text>
<text x="${width - padding.right}" y="${padding.top + chartHeight + 12}" text-anchor="end" style="font-size: 8px; fill: var(--text-muted)">${new Date(points[points.length - 1].timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" })}</text>
`
: nothing
}
<!-- Bars -->
${points.map((p, i) => {
const val = barTotals[i];
const x = padding.left + i * (barWidth + barGap);
const barHeight = (val / maxValue) * chartHeight;
const y = padding.top + chartHeight - barHeight;
const date = new Date(p.timestamp);
const tooltipLines = [
date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}),
`${formatTokens(val)} tokens`,
];
if (breakdownByType) {
tooltipLines.push(`Output ${formatTokens(p.output)}`);
tooltipLines.push(`Input ${formatTokens(p.input)}`);
tooltipLines.push(`Cache write ${formatTokens(p.cacheWrite)}`);
tooltipLines.push(`Cache read ${formatTokens(p.cacheRead)}`);
<div class="timeseries-chart-wrapper" style="position: relative; cursor: crosshair;">
<svg
viewBox="0 0 ${width} ${height + 18}"
class="timeseries-svg"
style="width: 100%; height: auto; display: block;"
>
<!-- Y axis -->
<line x1="${padding.left}" y1="${padding.top}" x2="${padding.left}" y2="${padding.top + chartHeight}" stroke="var(--border)" />
<!-- X axis -->
<line x1="${padding.left}" y1="${padding.top + chartHeight}" x2="${width - padding.right}" y2="${padding.top + chartHeight}" stroke="var(--border)" />
<!-- Y axis labels -->
<text x="${padding.left - 4}" y="${padding.top + 5}" text-anchor="end" class="ts-axis-label">${formatTokens(maxValue)}</text>
<text x="${padding.left - 4}" y="${padding.top + chartHeight}" text-anchor="end" class="ts-axis-label">0</text>
<!-- X axis labels (first and last) -->
${
points.length > 0
? svg`
<text x="${padding.left}" y="${padding.top + chartHeight + 10}" text-anchor="start" class="ts-axis-label">${new Date(points[0].timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}</text>
<text x="${width - padding.right}" y="${padding.top + chartHeight + 10}" text-anchor="end" class="ts-axis-label">${new Date(points[points.length - 1].timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}</text>
`
: nothing
}
const tooltip = tooltipLines.join(" · ");
if (!breakdownByType) {
return svg`<rect x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" class="ts-bar" rx="1" style="cursor: pointer;"><title>${tooltip}</title></rect>`;
}
const segments = [
{ value: p.output, class: "output" },
{ value: p.input, class: "input" },
{ value: p.cacheWrite, class: "cache-write" },
{ value: p.cacheRead, class: "cache-read" },
];
let yCursor = padding.top + chartHeight;
return svg`
${segments.map((seg) => {
if (seg.value <= 0 || val <= 0) {
return nothing;
<!-- Bars -->
${points.map((p, i) => {
const val = barTotals[i];
const x = padding.left + i * (barWidth + barGap);
const bh = (val / maxValue) * chartHeight;
const y = padding.top + chartHeight - bh;
const date = new Date(p.timestamp);
const tooltipLines = [
date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}),
`${formatTokens(val)} tokens`,
];
if (breakdownByType) {
tooltipLines.push(`Out ${formatTokens(p.output)}`);
tooltipLines.push(`In ${formatTokens(p.input)}`);
tooltipLines.push(`CW ${formatTokens(p.cacheWrite)}`);
tooltipLines.push(`CR ${formatTokens(p.cacheRead)}`);
}
const tooltip = tooltipLines.join(" · ");
const isOutside = hasSelection && (i < rangeStartIdx || i >= rangeEndIdx);
if (!breakdownByType) {
return svg`<rect x="${x}" y="${y}" width="${barWidth}" height="${bh}" class="ts-bar${isOutside ? " dimmed" : ""}" rx="1"><title>${tooltip}</title></rect>`;
}
const segments = [
{ value: p.output, cls: "output" },
{ value: p.input, cls: "input" },
{ value: p.cacheWrite, cls: "cache-write" },
{ value: p.cacheRead, cls: "cache-read" },
];
let yC = padding.top + chartHeight;
const dim = isOutside ? " dimmed" : "";
return svg`
${segments.map((seg) => {
if (seg.value <= 0 || val <= 0) {
return nothing;
}
const sh = bh * (seg.value / val);
yC -= sh;
return svg`<rect x="${x}" y="${yC}" width="${barWidth}" height="${sh}" class="ts-bar ${seg.cls}${dim}" rx="1"><title>${tooltip}</title></rect>`;
})}
`;
})}
<!-- Selection highlight overlay (always visible between handles) -->
${svg`
<rect
x="${leftHandleX}"
y="${padding.top}"
width="${Math.max(1, rightHandleX - leftHandleX)}"
height="${chartHeight}"
fill="var(--accent)"
opacity="${CHART_SELECTION_OPACITY}"
pointer-events="none"
/>
`}
<!-- Left cursor line + handle -->
${svg`
<line x1="${leftHandleX}" y1="${padding.top}" x2="${leftHandleX}" y2="${padding.top + chartHeight}" stroke="var(--accent)" stroke-width="0.8" opacity="0.7" />
<rect x="${leftHandleX - HANDLE_WIDTH / 2}" y="${padding.top + chartHeight / 2 - HANDLE_HEIGHT / 2}" width="${HANDLE_WIDTH}" height="${HANDLE_HEIGHT}" rx="1.5" fill="var(--accent)" class="cursor-handle" />
<line x1="${leftHandleX - HANDLE_GRIP_OFFSET}" y1="${padding.top + chartHeight / 2 - HANDLE_HEIGHT / 5}" x2="${leftHandleX - HANDLE_GRIP_OFFSET}" y2="${padding.top + chartHeight / 2 + HANDLE_HEIGHT / 5}" stroke="var(--bg)" stroke-width="0.4" pointer-events="none" />
<line x1="${leftHandleX + HANDLE_GRIP_OFFSET}" y1="${padding.top + chartHeight / 2 - HANDLE_HEIGHT / 5}" x2="${leftHandleX + HANDLE_GRIP_OFFSET}" y2="${padding.top + chartHeight / 2 + HANDLE_HEIGHT / 5}" stroke="var(--bg)" stroke-width="0.4" pointer-events="none" />
`}
<!-- Right cursor line + handle -->
${svg`
<line x1="${rightHandleX}" y1="${padding.top}" x2="${rightHandleX}" y2="${padding.top + chartHeight}" stroke="var(--accent)" stroke-width="0.8" opacity="0.7" />
<rect x="${rightHandleX - HANDLE_WIDTH / 2}" y="${padding.top + chartHeight / 2 - HANDLE_HEIGHT / 2}" width="${HANDLE_WIDTH}" height="${HANDLE_HEIGHT}" rx="1.5" fill="var(--accent)" class="cursor-handle" />
<line x1="${rightHandleX - HANDLE_GRIP_OFFSET}" y1="${padding.top + chartHeight / 2 - HANDLE_HEIGHT / 5}" x2="${rightHandleX - HANDLE_GRIP_OFFSET}" y2="${padding.top + chartHeight / 2 + HANDLE_HEIGHT / 5}" stroke="var(--bg)" stroke-width="0.4" pointer-events="none" />
<line x1="${rightHandleX + HANDLE_GRIP_OFFSET}" y1="${padding.top + chartHeight / 2 - HANDLE_HEIGHT / 5}" x2="${rightHandleX + HANDLE_GRIP_OFFSET}" y2="${padding.top + chartHeight / 2 + HANDLE_HEIGHT / 5}" stroke="var(--bg)" stroke-width="0.4" pointer-events="none" />
`}
</svg>
<!-- Handle drag zones (only on handles, not full chart) -->
${(() => {
const leftHandlePos = `${((leftHandleX / width) * 100).toFixed(1)}%`;
const rightHandlePos = `${((rightHandleX / width) * 100).toFixed(1)}%`;
const makeDragHandler = (side: "left" | "right") => (e: MouseEvent) => {
if (!onCursorRangeChange) {
return;
}
e.preventDefault();
e.stopPropagation();
// Find the wrapper, then the SVG inside it
const wrapper = (e.currentTarget as HTMLElement).closest(".timeseries-chart-wrapper");
const svgEl = wrapper?.querySelector("svg") as SVGSVGElement;
if (!svgEl) {
return;
}
// Capture rect once at mousedown to avoid re-render offset shifts
const rect = svgEl.getBoundingClientRect();
const svgWidth = rect.width;
const chartLeftPx = (padding.left / width) * svgWidth;
const chartRightPx = ((width - padding.right) / width) * svgWidth;
const chartW = chartRightPx - chartLeftPx;
const posToIdx = (clientX: number) => {
const x = Math.max(0, Math.min(1, (clientX - rect.left - chartLeftPx) / chartW));
return Math.min(Math.floor(x * points.length), points.length - 1);
};
// Compute click offset: where on the handle the user grabbed
const handleSvgX = side === "left" ? leftHandleX : rightHandleX;
const handleClientX = rect.left + (handleSvgX / width) * svgWidth;
const grabOffset = e.clientX - handleClientX;
document.body.style.cursor = "col-resize";
const handleMove = (me: MouseEvent) => {
const adjustedX = me.clientX - grabOffset;
const idx = posToIdx(adjustedX);
const pt = points[idx];
if (!pt) {
return;
}
const segHeight = barHeight * (seg.value / val);
yCursor -= segHeight;
return svg`<rect x="${x}" y="${yCursor}" width="${barWidth}" height="${segHeight}" class="ts-bar ${seg.class}" rx="1"><title>${tooltip}</title></rect>`;
})}
if (side === "left") {
const endTs = cursorEnd ?? points[points.length - 1].timestamp;
// Don't let left go past right
onCursorRangeChange(Math.min(pt.timestamp, endTs), endTs);
} else {
const startTs = cursorStart ?? points[0].timestamp;
// Don't let right go past left
onCursorRangeChange(startTs, Math.max(pt.timestamp, startTs));
}
};
const handleUp = () => {
document.body.style.cursor = "";
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mouseup", handleUp);
};
document.addEventListener("mousemove", handleMove);
document.addEventListener("mouseup", handleUp);
};
return html`
<div class="chart-handle-zone chart-handle-left"
style="left: ${leftHandlePos};"
@mousedown=${makeDragHandler("left")}></div>
<div class="chart-handle-zone chart-handle-right"
style="left: ${rightHandlePos};"
@mousedown=${makeDragHandler("right")}></div>
`;
})}
</svg>
<div class="timeseries-summary">${points.length} msgs · ${formatTokens(cumTokens)} · ${formatCost(cumCost)}</div>
})()}
</div>
<div class="timeseries-summary">
${
hasSelection
? html`
<span style="color: var(--accent);">▶ Turns ${rangeStartIdx + 1}${rangeEndIdx} of ${points.length}</span> ·
${new Date(rangeStartTs).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}${new Date(rangeEndTs).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })} ·
${formatTokens(filteredOutput + filteredInput + filteredCacheRead + filteredCacheWrite)} ·
${formatCost(filteredPoints.reduce((s, p) => s + (p.cost || 0), 0))}
`
: html`${points.length} msgs · ${formatTokens(cumTokens)} · ${formatCost(cumCost)}`
}
</div>
${
breakdownByType
? html`
<div style="margin-top: 8px;">
<div class="card-title" style="font-size: 12px; margin-bottom: 6px;">Tokens by Type</div>
<div class="card-title" style="font-size: 12px; margin-bottom: 6px; color: var(--text);">Tokens by Type</div>
<div class="cost-breakdown-bar" style="height: 18px;">
<div class="cost-segment output" style="width: ${pct(sumOutput, totalTypeTokens).toFixed(1)}%"></div>
<div class="cost-segment input" style="width: ${pct(sumInput, totalTypeTokens).toFixed(1)}%"></div>
<div class="cost-segment cache-write" style="width: ${pct(sumCacheWrite, totalTypeTokens).toFixed(1)}%"></div>
<div class="cost-segment cache-read" style="width: ${pct(sumCacheRead, totalTypeTokens).toFixed(1)}%"></div>
<div class="cost-segment output" style="width: ${pct(filteredOutput, totalTypeTokens).toFixed(1)}%"></div>
<div class="cost-segment input" style="width: ${pct(filteredInput, totalTypeTokens).toFixed(1)}%"></div>
<div class="cost-segment cache-write" style="width: ${pct(filteredCacheWrite, totalTypeTokens).toFixed(1)}%"></div>
<div class="cost-segment cache-read" style="width: ${pct(filteredCacheRead, totalTypeTokens).toFixed(1)}%"></div>
</div>
<div class="cost-breakdown-legend">
<div class="legend-item" title="Assistant output tokens">
<span class="legend-dot output"></span>Output ${formatTokens(sumOutput)}
<span class="legend-dot output"></span>Output ${formatTokens(filteredOutput)}
</div>
<div class="legend-item" title="User + tool input tokens">
<span class="legend-dot input"></span>Input ${formatTokens(sumInput)}
<span class="legend-dot input"></span>Input ${formatTokens(filteredInput)}
</div>
<div class="legend-item" title="Tokens written to cache">
<span class="legend-dot cache-write"></span>Cache Write ${formatTokens(sumCacheWrite)}
<span class="legend-dot cache-write"></span>Cache Write ${formatTokens(filteredCacheWrite)}
</div>
<div class="legend-item" title="Tokens read from cache">
<span class="legend-dot cache-read"></span>Cache Read ${formatTokens(sumCacheRead)}
<span class="legend-dot cache-read"></span>Cache Read ${formatTokens(filteredCacheRead)}
</div>
</div>
<div class="cost-breakdown-total">Total: ${formatTokens(totalTypeTokens)}</div>
@@ -404,6 +720,7 @@ function renderContextPanel(
usage: UsageSessionEntry["usage"],
expanded: boolean,
onToggleExpanded: () => void,
timeSeriesCursor?: number | null,
) {
if (!contextWeight) {
return html`
@@ -450,7 +767,7 @@ function renderContextPanel(
return html`
<div class="context-details-panel">
<div class="context-breakdown-header">
<div class="card-title" style="font-size: 13px;">System Prompt Breakdown</div>
<div class="card-title" style="font-size: 12px; color: var(--text);">System Prompt Breakdown</div>
${
hasMore
? html`<button class="context-expand-btn" @click=${onToggleExpanded}>
@@ -459,7 +776,13 @@ function renderContextPanel(
: nothing
}
</div>
<p class="context-weight-desc">${contextPct || "Base context per message"}</p>
<p class="context-weight-desc">
${
timeSeriesCursor !== null && timeSeriesCursor !== undefined
? "Current state (not filtered by timeline cursor)"
: contextPct || "Base context per message"
}
</p>
<div class="context-stacked-bar">
<div class="context-segment system" style="width: ${pct(systemTokens, totalContextTokens).toFixed(1)}%" title="System: ~${formatTokens(systemTokens)}"></div>
<div class="context-segment skills" style="width: ${pct(skillsTokens, totalContextTokens).toFixed(1)}%" title="Skills: ~${formatTokens(skillsTokens)}"></div>
@@ -576,6 +899,8 @@ function renderSessionLogsCompact(
onFilterHasToolsChange: (next: boolean) => void,
onFilterQueryChange: (next: string) => void,
onFilterClear: () => void,
cursorStart?: number | null,
cursorEnd?: number | null,
) {
if (loading) {
return html`
@@ -604,6 +929,18 @@ function renderSessionLogsCompact(
new Set(entries.flatMap((entry) => entry.toolInfo.tools.map(([name]) => name))),
).toSorted((a, b) => a.localeCompare(b));
const filteredEntries = entries.filter((entry) => {
// Filter by cursor timeline range (only if logs cover the range)
if (cursorStart != null && cursorEnd != null) {
const ts = entry.log.timestamp;
if (ts > 0) {
const lo = Math.min(cursorStart, cursorEnd);
const hi = Math.max(cursorStart, cursorEnd);
const normalizedTs = normalizeLogTimestamp(ts);
if (normalizedTs < lo || normalizedTs > hi) {
return false;
}
}
}
if (filters.roles.length > 0 && !filters.roles.includes(entry.log.role)) {
return false;
}
@@ -624,9 +961,12 @@ function renderSessionLogsCompact(
}
return true;
});
const hasActiveFilters =
filters.roles.length > 0 || filters.tools.length > 0 || filters.hasTools || normalizedQuery;
const hasCursorFilter = cursorStart != null && cursorEnd != null;
const displayedCount =
filters.roles.length > 0 || filters.tools.length > 0 || filters.hasTools || normalizedQuery
? `${filteredEntries.length} of ${logs.length}`
hasActiveFilters || hasCursorFilter
? `${filteredEntries.length} of ${logs.length} ${hasCursorFilter ? "(timeline filtered)" : ""}`
: `${logs.length}`;
const roleSelected = new Set(filters.roles);
@@ -635,7 +975,7 @@ function renderSessionLogsCompact(
return html`
<div class="session-logs-compact">
<div class="session-logs-header">
<span>Conversation <span style="font-weight: normal; color: var(--text-muted);">(${displayedCount} messages)</span></span>
<span>Conversation <span style="font-weight: normal; color: var(--muted);">(${displayedCount} messages)</span></span>
<button class="btn btn-sm usage-action-btn usage-secondary-btn" @click=${onToggleExpandedAll}>
${expandedAll ? "Collapse All" : "Expand All"}
</button>
@@ -736,10 +1076,13 @@ function renderSessionLogsCompact(
}
export {
computeFilteredUsage,
renderContextPanel,
renderEmptyDetailState,
renderSessionDetailPanel,
renderSessionLogsCompact,
renderSessionSummary,
renderTimeSeriesCompact,
CHART_BAR_WIDTH_RATIO,
CHART_MAX_BAR_WIDTH,
};

View File

@@ -509,4 +509,43 @@ export const usageStylesPart3 = `
height: 22px;
}
}
/* ===== CHART AXIS ===== */
.ts-axis-label {
font-size: 5px;
fill: var(--text-muted);
}
/* ===== RANGE SELECTION HANDLES ===== */
.chart-handle-zone {
position: absolute;
top: 0;
width: 16px;
height: 100%;
cursor: col-resize;
z-index: 10;
transform: translateX(-50%);
}
.timeseries-chart-wrapper {
position: relative;
}
.timeseries-reset-btn {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 999px;
padding: 2px 10px;
font-size: 11px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s ease;
margin-left: 8px;
}
.timeseries-reset-btn:hover {
background: var(--bg-hover);
color: var(--text);
border-color: var(--border-strong);
}
`;

View File

@@ -811,6 +811,9 @@ export function renderUsage(props: UsageProps) {
props.onTimeSeriesModeChange,
props.timeSeriesBreakdownMode,
props.onTimeSeriesBreakdownChange,
props.timeSeriesCursorStart,
props.timeSeriesCursorEnd,
props.onTimeSeriesCursorRangeChange,
props.startDate,
props.endDate,
props.selectedDays,

View File

@@ -42,6 +42,8 @@ export type UsageProps = {
timeSeriesBreakdownMode: "total" | "by-type";
timeSeries: { points: TimeSeriesPoint[] } | null;
timeSeriesLoading: boolean;
timeSeriesCursorStart: number | null; // Start of selected range (null = no selection)
timeSeriesCursorEnd: number | null; // End of selected range (null = no selection)
sessionLogs: SessionLogEntry[] | null;
sessionLogsLoading: boolean;
sessionLogsExpanded: boolean;
@@ -76,6 +78,7 @@ export type UsageProps = {
onDailyChartModeChange: (mode: "total" | "by-type") => void;
onTimeSeriesModeChange: (mode: "cumulative" | "per-turn") => void;
onTimeSeriesBreakdownChange: (mode: "total" | "by-type") => void;
onTimeSeriesCursorRangeChange: (start: number | null, end: number | null) => void;
onSelectDay: (day: string, shiftKey: boolean) => void; // Support shift-click
onSelectHour: (hour: number, shiftKey: boolean) => void;
onClearDays: () => void;