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
2 changes: 1 addition & 1 deletion frontend/e2e/session-count-consistency.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ test.describe("Session count consistency", () => {
// Wait for the summary to finish loading.
const sessionsCard = summaryCards
.locator(".card")
.filter({ has: page.locator(".card-label", { hasText: "Sessions" }) });
.filter({ has: page.locator(".card-label", { hasText: /^Sessions$/ }) });
await expect(
sessionsCard.locator(".card-value"),
).not.toHaveText("--", { timeout: 10_000 });
Expand Down
60 changes: 60 additions & 0 deletions frontend/src/lib/api/client-token-metrics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
} from "vitest";
import {
getAnalyticsHeatmap,
getAnalyticsTopSessions,
} from "./client.js";

describe("analytics token metric query serialization", () => {
let fetchSpy: ReturnType<typeof vi.fn>;
let localStorageGetItem: ReturnType<typeof vi.fn>;

beforeEach(() => {
fetchSpy = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
});
vi.stubGlobal("fetch", fetchSpy);
localStorageGetItem = vi.fn().mockReturnValue(null);
vi.stubGlobal("localStorage", {
getItem: localStorageGetItem,
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
});
});

afterEach(() => {
vi.unstubAllGlobals();
});

it("serializes output_tokens for heatmap", async () => {
await getAnalyticsHeatmap({
from: "2024-01-01",
metric: "output_tokens",
});

expect(fetchSpy).toHaveBeenCalledWith(
"/api/v1/analytics/heatmap?from=2024-01-01&metric=output_tokens",
expect.any(Object),
);
});

it("serializes output_tokens for top sessions", async () => {
await getAnalyticsTopSessions({
from: "2024-01-01",
metric: "output_tokens",
});

expect(fetchSpy).toHaveBeenCalledWith(
"/api/v1/analytics/top-sessions?from=2024-01-01&metric=output_tokens",
expect.any(Object),
);
});
});
17 changes: 13 additions & 4 deletions frontend/src/lib/api/types/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
/** Analytics types — match Go structs in internal/db/analytics.go */

export type Granularity = "day" | "week" | "month";
export type HeatmapMetric = "messages" | "sessions";
export type TopSessionsMetric = "messages" | "duration";
export type HeatmapMetric =
| "messages"
| "sessions"
| "output_tokens";
export type TopSessionsMetric =
| "messages"
| "duration"
| "output_tokens";

export interface AgentSummary {
sessions: number;
Expand All @@ -12,6 +18,8 @@ export interface AgentSummary {
export interface AnalyticsSummary {
total_sessions: number;
total_messages: number;
total_output_tokens?: number;
token_reporting_sessions?: number;
active_projects: number;
active_days: number;
avg_messages: number;
Expand Down Expand Up @@ -52,7 +60,7 @@ export interface HeatmapLevels {
}

export interface HeatmapResponse {
metric: string;
metric: HeatmapMetric;
entries: HeatmapEntry[];
levels: HeatmapLevels;
entries_from: string;
Expand Down Expand Up @@ -126,11 +134,12 @@ export interface TopSession {
project: string;
first_message: string | null;
message_count: number;
output_tokens: number;
duration_min: number;
}

export interface TopSessionsResponse {
metric: string;
metric: TopSessionsMetric;
sessions: TopSession[];
}

Expand Down
4 changes: 4 additions & 0 deletions frontend/src/lib/api/types/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface Session {
file_mtime?: number;
total_output_tokens: number;
peak_context_tokens: number;
has_total_output_tokens?: boolean;
has_peak_context_tokens?: boolean;
created_at: string;
}

Expand Down Expand Up @@ -82,6 +84,8 @@ export interface Message {
token_usage?: Record<string, number | boolean> | null;
context_tokens: number;
output_tokens: number;
has_context_tokens?: boolean;
has_output_tokens?: boolean;
tool_calls?: ToolCall[];
is_system: boolean;
}
Expand Down
27 changes: 26 additions & 1 deletion frontend/src/lib/components/analytics/Heatmap.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { analytics } from "../../stores/analytics.svelte.js";
import type { HeatmapMetric } from "../../stores/analytics.svelte.js";

const CELL_SIZE = 16;
const CELL_GAP = 2;
Expand Down Expand Up @@ -39,6 +40,17 @@
return colors[level] ?? colors[0]!;
}

function metricLabel(metric: HeatmapMetric): string {
switch (metric) {
case "sessions":
return "Sessions";
case "output_tokens":
return "Output Tokens";
default:
return "Messages";
}
}

let tooltip = $state<{
x: number;
y: number;
Expand All @@ -61,7 +73,7 @@
tooltip = {
x: rect.left + rect.width / 2,
y: rect.top - 4,
text: `${label}: ${cell.value.toLocaleString()} ${analytics.metric}`,
text: `${label}: ${cell.value.toLocaleString()} ${metricLabel(analytics.metric)}`,
};
}

Expand Down Expand Up @@ -121,6 +133,10 @@
grid.cols.length * CELL_STEP + LABEL_WIDTH + 4,
);
const svgHeight = 7 * CELL_STEP + HEADER_HEIGHT + 4;
const supportsOutputTokens = $derived(
analytics.summary?.total_output_tokens !== undefined &&
analytics.summary?.token_reporting_sessions !== undefined,
);
</script>

<div class="heatmap-container">
Expand All @@ -141,6 +157,15 @@
>
Sessions
</button>
{#if supportsOutputTokens}
<button
class="toggle-btn"
class:active={analytics.metric === "output_tokens"}
onclick={() => analytics.setMetric("output_tokens")}
>
Output Tokens
</button>
{/if}
</div>
</div>

Expand Down
20 changes: 20 additions & 0 deletions frontend/src/lib/components/analytics/SummaryCards.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
return n.toLocaleString();
}

function formatOptionalNum(
n: number | null | undefined,
): string {
return typeof n === "number" ? formatNum(n) : "—";
}

function pct(n: number): string {
return `${(n * 100).toFixed(1)}%`;
}
Expand All @@ -26,6 +32,20 @@
value: () =>
formatNum(analytics.summary?.total_messages ?? 0),
},
{
label: "Output Tokens",
value: () =>
formatOptionalNum(
analytics.summary?.total_output_tokens,
),
},
{
label: "Reporting Sessions",
value: () =>
formatOptionalNum(
analytics.summary?.token_reporting_sessions,
),
},
{
label: "Projects",
value: () =>
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/lib/components/analytics/TopSessions.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { analytics } from "../../stores/analytics.svelte.js";
import { sessions } from "../../stores/sessions.svelte.js";
import { router } from "../../stores/router.svelte.js";
import { formatTokenCount } from "../../utils/format.js";

function truncate(text: string, max: number): string {
if (text.length <= max) return text;
Expand All @@ -23,6 +24,11 @@
}
router.navigateToSession(id);
}

const supportsOutputTokens = $derived(
analytics.summary?.total_output_tokens !== undefined &&
analytics.summary?.token_reporting_sessions !== undefined,
);
</script>

<div class="top-sessions-container">
Expand All @@ -43,6 +49,15 @@
>
By Duration
</button>
{#if supportsOutputTokens}
<button
class="toggle-btn"
class:active={analytics.topMetric === "output_tokens"}
onclick={() => analytics.setTopMetric("output_tokens")}
>
By Output Tokens
</button>
{/if}
</div>
</div>

Expand Down Expand Up @@ -79,6 +94,8 @@
<span class="session-metric">
{#if analytics.topMetric === "duration"}
{formatDuration(session.duration_min)}
{:else if analytics.topMetric === "output_tokens"}
{formatTokenCount(session.output_tokens)}
{:else}
{session.message_count}
{/if}
Expand Down
57 changes: 49 additions & 8 deletions frontend/src/lib/components/content/MessageContent.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
parseContent,
enrichSegments,
} from "../../utils/content-parser.js";
import { formatTimestamp } from "../../utils/format.js";
import {
formatTimestamp,
formatTokenUsage,
} from "../../utils/format.js";
import { copyToClipboard } from "../../utils/clipboard.js";
import { messages as messagesStore } from "../../stores/messages.svelte.js";
import ThinkingBlock from "./ThinkingBlock.svelte";
Expand Down Expand Up @@ -50,6 +53,23 @@
return message.model !== mainModel ? message.model : "";
});

let hasContextTokens = $derived(
message.has_context_tokens ?? message.context_tokens > 0,
);

let hasOutputTokens = $derived(
message.has_output_tokens ?? message.output_tokens > 0,
);

let tokenSummary = $derived(
formatTokenUsage(
message.context_tokens,
hasContextTokens,
message.output_tokens,
hasOutputTokens,
),
);

/** Resolve the session that owns this message, falling back to activeSession. */
let owningSession = $derived(
sessions.sessions.find((s) => s.id === message.session_id) ??
Expand Down Expand Up @@ -212,14 +232,21 @@
{#if pinFeedback}
<span class="pin-feedback">{pinFeedback}</span>
{/if}
<span class="timestamp">
{formatTimestamp(message.timestamp)}
</span>
{#if offMainModel}
<span class="message-model" title={offMainModel}>
{offMainModel}
<div class="header-meta">
{#if tokenSummary}
<span class="message-tokens">
{tokenSummary}
</span>
{/if}
<span class="timestamp">
{formatTimestamp(message.timestamp)}
</span>
{/if}
{#if offMainModel}
<span class="message-model" title={offMainModel}>
{offMainModel}
</span>
{/if}
</div>
</div>

<div class="message-body">
Expand Down Expand Up @@ -310,7 +337,21 @@
.timestamp {
font-size: 12px;
color: var(--text-muted);
}

.header-meta {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}

.message-tokens {
font-size: 10px;
color: var(--text-muted);
font-family: var(--font-mono);
white-space: nowrap;
}

.message-model {
Expand Down
Loading