Skip to content
Open
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
72 changes: 56 additions & 16 deletions src-tauri/src/commands/token_usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,14 @@ fn bucket_key(date: NaiveDate, bucket: TokenUsageBucket) -> String {

// ─── Aggregation ────────────────────────────────────────────────────────

/// Per-conversation running sums, keyed by conversation id:
/// `(total_tokens, output_tokens, duration_ms, turns, last_activity, agent, folder_id)`.
type ConversationAcc = (u64, u64, u64, u64, DateTime<Utc>, String, i32);

/// [`ConversationAcc`] with the conversation id folded in as the first element,
/// so the top-conversations list can be sorted as one flat tuple.
type ConversationTotals = (i32, u64, u64, u64, u64, DateTime<Utc>, String, i32);

/// Running sums for one group (a bucket, or one slice of a breakdown).
#[derive(Debug, Default, Clone)]
struct Acc {
Expand Down Expand Up @@ -416,7 +424,7 @@ pub(crate) fn aggregate_report(
let mut by_agent: HashMap<String, Acc> = HashMap::new();
let mut by_model: HashMap<String, Acc> = HashMap::new();
let mut heat: HashMap<(u8, u8), (u64, u64)> = HashMap::new();
let mut per_conversation: HashMap<i32, (u64, u64, DateTime<Utc>, String, i32)> = HashMap::new();
let mut per_conversation: HashMap<i32, ConversationAcc> = HashMap::new();
let mut active_dates: HashSet<NaiveDate> = HashSet::new();
let mut first_activity: Option<DateTime<Utc>> = None;
let mut last_activity: Option<DateTime<Utc>> = None;
Expand Down Expand Up @@ -452,16 +460,20 @@ pub(crate) fn aggregate_report(
cell.1 += 1;

let entry = per_conversation.entry(row.conversation_id).or_insert((
0,
0,
0,
0,
row.occurred_at,
row.agent_type.clone(),
row.folder_id,
));
entry.0 += row.total_tokens.max(0) as u64;
entry.1 += 1;
if row.occurred_at > entry.2 {
entry.2 = row.occurred_at;
entry.1 += row.output_tokens.max(0) as u64;
entry.2 += row.duration_ms.max(0) as u64;
entry.3 += 1;
if row.occurred_at > entry.4 {
entry.4 = row.occurred_at;
}

active_dates.insert(date);
Expand Down Expand Up @@ -518,10 +530,10 @@ pub(crate) fn aggregate_report(
.collect();
heatmap.sort_by_key(|a| (a.weekday, a.hour));

let mut top: Vec<(i32, u64, u64, DateTime<Utc>, String, i32)> = per_conversation
let mut top: Vec<ConversationTotals> = per_conversation
.into_iter()
.map(|(id, (tokens, turns, last, agent, folder_id))| {
(id, tokens, turns, last, agent, folder_id)
.map(|(id, (tokens, output, duration_ms, turns, last, agent, folder_id))| {
(id, tokens, output, duration_ms, turns, last, agent, folder_id)
})
.collect();
// Ties broken by id so the list is stable across identical requests.
Expand All @@ -530,15 +542,19 @@ pub(crate) fn aggregate_report(
let top_conversations: Vec<TokenUsageConversationItem> = top
.into_iter()
.map(
|(id, tokens, turns, last, agent, folder_id)| TokenUsageConversationItem {
conversation_id: id,
// Filled by the command layer, which owns the DB handle.
title: None,
agent_type: agent,
folder_label: opts.folder_labels.get(&folder_id).cloned(),
total_tokens: tokens,
turn_count: turns,
last_activity_at: last,
|(id, tokens, output, duration_ms, turns, last, agent, folder_id)| {
TokenUsageConversationItem {
conversation_id: id,
// Filled by the command layer, which owns the DB handle.
title: None,
agent_type: agent,
folder_label: opts.folder_labels.get(&folder_id).cloned(),
total_tokens: tokens,
output_tokens: output,
turn_count: turns,
duration_ms,
last_activity_at: last,
}
},
)
.collect();
Expand Down Expand Up @@ -1625,6 +1641,30 @@ mod tests {
assert_eq!(report.by_model[0].key, UNKNOWN_MODEL);
}

#[test]
fn top_conversations_carry_output_tokens_and_duration() {
let labels = HashMap::new();
let mut heavy = row("2026-08-01T10:00:00Z", 100);
heavy.output_tokens = 40;
heavy.duration_ms = 2000;
let mut light = row("2026-08-01T11:00:00Z", 10);
light.conversation_id = 2;
light.output_tokens = 5;
light.duration_ms = 500;

let report = aggregate_report(
&[heavy, light],
&[],
&opts(&labels, TokenUsageBucket::Day, 0, None, None),
);
assert_eq!(report.top_conversations[0].conversation_id, 1);
assert_eq!(report.top_conversations[0].output_tokens, 40);
assert_eq!(report.top_conversations[0].duration_ms, 2000);
assert_eq!(report.top_conversations[1].conversation_id, 2);
assert_eq!(report.top_conversations[1].output_tokens, 5);
assert_eq!(report.top_conversations[1].duration_ms, 500);
}

#[test]
fn heatmap_uses_local_weekday_and_hour() {
let labels = HashMap::new();
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/models/token_usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,10 @@ pub struct TokenUsageConversationItem {
pub agent_type: String,
pub folder_label: Option<String>,
pub total_tokens: u64,
pub output_tokens: u64,
pub turn_count: u64,
/// Summed recorded generation time of the counted turns.
pub duration_ms: u64,
pub last_activity_at: DateTime<Utc>,
}

Expand Down
32 changes: 26 additions & 6 deletions src/components/conversations/session-details-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
import { getFolderConversation } from "@/lib/api"
import { useCopiedFlag } from "@/hooks/use-copied-flag"
import { pickModelFromTurns } from "./active-session-details"
import { useSessionOutputSpeed } from "@/hooks/use-session-output-speed"
import { averageOutputTps, formatTokPerSec } from "@/lib/token-speed"
import { AgentIcon } from "@/components/agent-icon"
import { ConversationStatusDot } from "./conversation-status-dot"

Expand Down Expand Up @@ -141,15 +143,17 @@ export function InfoItem({
children,
className,
valueClassName,
title,
}: {
/** Usually plain text; a node so a caller can hang a badge off the label. */
label: ReactNode
children: ReactNode
className?: string
valueClassName?: string
title?: string
}) {
return (
<div className={cn("min-w-0 space-y-0.5", className)}>
<div className={cn("min-w-0 space-y-0.5", className)} title={title}>
<dt className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
{label}
</dt>
Expand Down Expand Up @@ -312,6 +316,12 @@ export function SessionDetailsContent({
ctxMax
)
const durationMs = resolveSessionDurationMs(summary, stats)
const liveOutputSpeed = useSessionOutputSpeed(summary.id)
const recordedOutputTps = averageOutputTps(
usage?.output_tokens ?? 0,
stats?.total_duration_ms ?? 0
)
const outputTps = liveOutputSpeed?.averageTps ?? recordedOutputTps
// Never coerce an unknown `used` to 0 — some parsers infer the model's
// context cap without any usage figure, so render "— / max" rather than a
// bogus "0 / max".
Expand All @@ -324,11 +334,12 @@ export function SessionDetailsContent({
? formatTokenCount(ctxUsed)
: null
const hasTokenInfo =
stats != null &&
(totalTokens != null ||
usage != null ||
contextWindowValue != null ||
durationMs > 0)
outputTps != null ||
(stats != null &&
(totalTokens != null ||
usage != null ||
contextWindowValue != null ||
durationMs > 0))

const numeric = "font-mono tabular-nums"

Expand Down Expand Up @@ -434,6 +445,15 @@ export function SessionDetailsContent({
{formatDuration(durationMs)}
</InfoItem>
)}
{outputTps != null && (
<InfoItem
label={t("outputSpeed")}
valueClassName={numeric}
title={t("outputSpeedTooltip")}
>
{formatTokPerSec(outputTps)}
</InfoItem>
)}
</dl>
) : (
<div className="text-muted-foreground">{t("noStats")}</div>
Expand Down
7 changes: 5 additions & 2 deletions src/components/message/live-turn-stats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ import { FilePenLine, Plane, Timer } from "lucide-react"
import type { AgentType } from "@/lib/types"
import { AgentIcon } from "@/components/agent-icon"
import { useTokenOutputSpeed } from "@/hooks/use-token-output-speed"
import { formatTokPerSec } from "@/lib/token-speed"

interface LiveTurnStatsProps {
message: LiveMessage
agentType: AgentType
isStreaming?: boolean
conversationId?: number | null
}

interface LineChangeStats {
Expand Down Expand Up @@ -311,12 +313,13 @@ export function LiveTurnStats({
message,
agentType,
isStreaming = true,
conversationId = null,
}: LiveTurnStatsProps) {
const locale = useLocale()
const t = useTranslations("Folder.chat.liveTurnStats")
const [elapsed, setElapsed] = useState(() => Date.now() - message.startedAt)
const editStats = useMemo(() => extractLiveEditStats(message), [message])
const tps = useTokenOutputSpeed(message)
const tps = useTokenOutputSpeed(message, { conversationId })
const compactNumberFormatter = useMemo(
() =>
new Intl.NumberFormat(locale, {
Expand Down Expand Up @@ -392,7 +395,7 @@ export function LiveTurnStats({
aria-label={t("outputSpeedAria")}
className="h-3 w-3 shrink-0"
/>
{tps.toFixed(1)} tok/s
{formatTokPerSec(tps)}
</span>
</>
)}
Expand Down
1 change: 1 addition & 0 deletions src/components/message/message-list-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1154,6 +1154,7 @@ export function MessageListView({
message={liveMessage}
agentType={agentType}
isStreaming={connStatus === "prompting"}
conversationId={conversationId}
/>
)}
{/* Shared overlay stack pinned to the inline-start edge (top-left in LTR,
Expand Down
78 changes: 51 additions & 27 deletions src/components/token-usage/token-usage-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { toErrorMessage } from "@/lib/app-error"
import { getAgentLabel } from "@/lib/custom-agents"
import { subscribe } from "@/lib/platform"
import { formatTokenCount } from "@/lib/token-format"
import { averageOutputTps, formatTokPerSec } from "@/lib/token-speed"
import {
averagePerActiveDay,
averagePerConversation,
Expand Down Expand Up @@ -561,6 +562,9 @@ export function TokenUsagePage() {

const totals = report?.totals
const cache = totals ? cacheHitRate(totals) : null
const averageTps = totals
? averageOutputTps(totals.output_tokens, totals.duration_ms)
: null
const heat = useMemo(
() => buildHeatMatrix(report?.heatmap ?? []),
[report?.heatmap]
Expand Down Expand Up @@ -1204,9 +1208,15 @@ export function TokenUsagePage() {
className="border-s border-t lg:border-t-0"
label={t("tileGenTime")}
value={formatDuration(totals.duration_ms)}
hint={`${t("avgPerActiveDay")} ${formatTokenCount(
Math.round(averagePerActiveDay(totals))
)}`}
hint={
averageTps != null
? t("avgOutputSpeed", {
speed: formatTokPerSec(averageTps),
})
: `${t("avgPerActiveDay")} ${formatTokenCount(
Math.round(averagePerActiveDay(totals))
)}`
}
/>
</section>

Expand Down Expand Up @@ -1384,31 +1394,45 @@ export function TokenUsagePage() {
</p>
) : (
<ol className="divide-y divide-border">
{report.top_conversations.map((c, i) => (
<li
key={c.conversation_id}
className="flex items-center gap-3 py-2 first:pt-0 last:pb-0"
>
<span
aria-hidden="true"
className="w-5 shrink-0 font-mono text-[0.625rem] tabular-nums text-muted-foreground/70"
{report.top_conversations.map((c, i) => {
const sessionTps = averageOutputTps(
c.output_tokens,
c.duration_ms
)
return (
<li
key={c.conversation_id}
className="flex items-center gap-3 py-2 first:pt-0 last:pb-0"
>
{String(i + 1).padStart(2, "0")}
</span>
<span className="min-w-0 flex-1 truncate text-[0.8125rem]">
{c.title || t("untitledSession")}
</span>
<span className="hidden shrink-0 truncate text-xs text-muted-foreground sm:block sm:max-w-[10rem]">
{c.folder_label}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{getAgentLabel(c.agent_type as AgentType)}
</span>
<span className="shrink-0 font-mono text-xs tabular-nums">
{formatTokenCount(c.total_tokens)}
</span>
</li>
))}
<span
aria-hidden="true"
className="w-5 shrink-0 font-mono text-[0.625rem] tabular-nums text-muted-foreground/70"
>
{String(i + 1).padStart(2, "0")}
</span>
<span className="min-w-0 flex-1 truncate text-[0.8125rem]">
{c.title || t("untitledSession")}
</span>
<span className="hidden shrink-0 truncate text-xs text-muted-foreground sm:block sm:max-w-[10rem]">
{c.folder_label}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{getAgentLabel(c.agent_type as AgentType)}
</span>
<span className="shrink-0 font-mono text-xs tabular-nums">
{formatTokenCount(c.total_tokens)}
</span>
{sessionTps != null && (
<span
className="hidden shrink-0 font-mono text-xs tabular-nums text-muted-foreground sm:block"
title={t("outputSpeedTooltip")}
>
{formatTokPerSec(sessionTps)}
</span>
)}
</li>
)
})}
</ol>
)}
</Panel>
Expand Down
23 changes: 23 additions & 0 deletions src/hooks/use-session-output-speed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"use client"

import { useCallback, useSyncExternalStore } from "react"

import {
getSessionOutputSpeed,
subscribeSessionOutputSpeed,
type SessionOutputSpeed,
} from "@/lib/session-output-speed"

export function useSessionOutputSpeed(
conversationId: number | null | undefined
): SessionOutputSpeed | null {
const subscribe = useCallback(
(onStoreChange: () => void) => subscribeSessionOutputSpeed(onStoreChange),
[]
)
const getSnapshot = useCallback(
() => getSessionOutputSpeed(conversationId),
[conversationId]
)
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
}
Loading
Loading