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
74 changes: 48 additions & 26 deletions src/main/profile/coreStats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,43 +131,56 @@ function computeAiActions(rows: UsageEventRow[]): ProfileAiAction[] {

function computeSkills(rows: UsageEventRow[]): {
skills: ProfileSkillUsage[];
subagents: ProfileSkillUsage[];
explored: number;
total: number;
mcps: ProfileSkillUsage[];
} {
const skillCounts = new Map<
string,
{ kind: "skill" | "subagent"; name: string; runCount: number }
>();
const skillCounts = new Map<string, number>();
const subagentCounts = new Map<string, number>();
const mcpCounts = new Map<string, number>();
let total = 0;
for (const row of rows) {
if (row.kind === "skill" || row.kind === "subagent") {
const name = row.name ?? row.kind;
const key = `${row.kind}:${name}`;
const existing = skillCounts.get(key);
if (existing) existing.runCount++;
else skillCounts.set(key, { kind: row.kind, name, runCount: 1 });
if (row.kind === "skill") {
skillCounts.set(row.name ?? "skill", (skillCounts.get(row.name ?? "skill") ?? 0) + 1);
total++;
} else if (row.kind === "subagent") {
const name = row.name ?? "subagent";
subagentCounts.set(name, (subagentCounts.get(name) ?? 0) + 1);
total++;
} else if (row.kind === "mcp") {
const name = row.name ?? "mcp";
mcpCounts.set(name, (mcpCounts.get(name) ?? 0) + 1);
}
}
const skills = [...skillCounts.values()]
.sort((a, b) => b.runCount - a.runCount)
.slice(0, MAX_SKILLS)
.map((s) => ({
name: s.name,
displayName: s.kind === "skill" ? `$${s.name}` : `@${s.name}`,
kind: s.kind,
runCount: s.runCount,
}));
const mcps: ProfileSkillUsage[] = [...mcpCounts.entries()]
const topBy = (
counts: Map<string, number>,
kind: ProfileSkillUsage["kind"],
display: (name: string) => string,
): ProfileSkillUsage[] =>
[...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, MAX_SKILLS)
.map(([name, runCount]) => ({ name, displayName: display(name), kind, runCount }));

const skills = topBy(skillCounts, "skill", (n) => `$${n}`);
const subagents = topBy(subagentCounts, "subagent", (n) => `@${n}`);
const mcps = topBy(mcpCounts, "mcp", (n) => n);
// "Explored" / "total" still span skills + subagents, matching the Activity
// insights labels (a subagent run is a skill-like invocation for that metric).
return { skills, subagents, explored: skillCounts.size + subagentCounts.size, total, mcps };
}

/** Distinct accounts (account-scoped provider kinds) seen across all events. */
function collectAccounts(rows: UsageEventRow[]): { key: string; label: string }[] {
const counts = new Map<string, number>();
for (const row of rows) {
if (!row.provider) continue;
counts.set(row.provider, (counts.get(row.provider) ?? 0) + 1);
}
return [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, MAX_SKILLS)
.map(([name, runCount]) => ({ name, displayName: name, kind: "mcp", runCount }));
return { skills, explored: skillCounts.size, total, mcps };
.map(([key]) => ({ key, label: accountLabel(key) }));
}

// -- Entry point ------------------------------------------------------
Expand Down Expand Up @@ -206,8 +219,10 @@ function emptyCoreStats(
accounts: [],
models: [],
skills: [],
subagents: [],
mcps: [],
aiActions: [],
availableAccounts: [],
};
}

Expand All @@ -223,7 +238,7 @@ export function computeProfileCoreStats(req: ProfileStatsRequest): ProfileCoreSt
const todayIndex = localDayIndex(generatedAt, offset);

const generation = getProfileDataGeneration();
const cacheKey = `${offset}|${todayIndex}|${req.scope ?? "device"}|${req.deviceId ?? "current"}`;
const cacheKey = `${offset}|${todayIndex}|${req.scope ?? "device"}|${req.deviceId ?? "current"}|${req.provider ?? "all"}`;
const cached = coreCache.get(cacheKey);
if (cached && cached.generation === generation) return cached.result;

Expand All @@ -241,7 +256,12 @@ export function computeProfileCoreStats(req: ProfileStatsRequest): ProfileCoreSt
return empty;
}

const rows = dbGetAllUsageEvents();
const allRows = dbGetAllUsageEvents();
// The account picker lists every account ever seen, independent of the active
// filter, so selecting one account doesn't collapse the picker to itself.
const availableAccounts = collectAccounts(allRows);
// Scope every downstream aggregation to the selected account (whole page).
const rows = req.provider ? allRows.filter((r) => r.provider === req.provider) : allRows;

// -- thread starts -> totals + mode breakdown --
const modeCounts = new Map<string, number>();
Expand Down Expand Up @@ -320,7 +340,7 @@ export function computeProfileCoreStats(req: ProfileStatsRequest): ProfileCoreSt
}
}

const { skills, explored, total: totalSkillsUsed, mcps } = computeSkills(rows);
const { skills, subagents, explored, total: totalSkillsUsed, mcps } = computeSkills(rows);

const totals: ProfileTotals = {
totalThreads,
Expand Down Expand Up @@ -357,8 +377,10 @@ export function computeProfileCoreStats(req: ProfileStatsRequest): ProfileCoreSt
models,
modes,
skills,
subagents,
mcps,
aiActions: computeAiActions(rows),
availableAccounts,
};
coreCache.set(cacheKey, { generation, result });
return result;
Expand Down
4 changes: 3 additions & 1 deletion src/main/profile/tokenStats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export function computeProfileTokenStats(req: ProfileStatsRequest): ProfileToken
// Reuse the last aggregation until a usage write bumps the generation, so
// repeated opens don't re-scan the log.
const generation = getProfileDataGeneration();
const cacheKey = `${offset}|${todayIndex}|${req.scope ?? "device"}|${req.deviceId ?? "current"}`;
const cacheKey = `${offset}|${todayIndex}|${req.scope ?? "device"}|${req.deviceId ?? "current"}|${req.provider ?? "all"}`;
const cached = tokenCache.get(cacheKey);
if (cached && cached.generation === generation) return cached.result;

Expand All @@ -76,6 +76,8 @@ export function computeProfileTokenStats(req: ProfileStatsRequest): ProfileToken

for (const row of dbGetAllUsageEvents()) {
if (row.kind !== "tokens" || row.value <= 0) continue;
// Scope to the selected account (exact account-kind match) when filtering.
if (req.provider && row.provider !== req.provider) continue;
lifetimeTokens += row.value;
const day = dayKeyFromIndex(localDayIndex(row.ts, offset));
perDay.set(day, (perDay.get(day) ?? 0) + row.value);
Expand Down
16 changes: 14 additions & 2 deletions src/renderer/state/usageRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,21 @@ function classifyItem(itemType: string, payload: unknown): ItemHit | undefined {
return { kind: "skill", name: skill || "skill" };
}
const subagentType = str(args, "subagent_type");
if (p?.["isSubAgent"] === true || name === "Task" || name === "Workflow" || subagentType) {
if (
p?.["isSubAgent"] === true ||
name === "Task" ||
name === "Workflow" ||
name === "Agent" ||
subagentType
) {
// Prefer the agent type (Task/Agent); for workflows use the saved name or
// description (inline script workflows carry neither, so they bucket under a
// generic "workflow"); otherwise the task description.
const agent =
subagentType ?? (name === "Workflow" ? "workflow" : str(args, "description")) ?? "subagent";
subagentType ??
(name === "Workflow"
? (str(args, "name") ?? str(args, "description") ?? "workflow")
: (str(args, "description") ?? "subagent"));
return { kind: "subagent", name: agent };
}
return undefined;
Expand Down
34 changes: 34 additions & 0 deletions src/renderer/views/ProfileOverlay/parts/AccountFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { ListFilter } from "lucide-react";
import type { ProfileAccountRef } from "@/shared/contracts";
import { ProviderIcon } from "@/renderer/components/providers/ProviderIcon";
import { DevicePicker, type DeviceOption } from "./DevicePicker";

const ALL_ACCOUNTS = "__all__";

/**
* Per-account stats filter. A thin wrapper over the shared monochrome
* {@link DevicePicker} shell: lists every account with its provider icon plus an
* "All accounts" entry. `undefined` value = no filter.
*/
export function AccountFilter(props: {
value: string | undefined;
options: ProfileAccountRef[];
onChange: (account: string | undefined) => void;
}) {
const { value, options, onChange } = props;
const pickerOptions: DeviceOption[] = [
{ id: ALL_ACCOUNTS, label: "All accounts", icon: <ListFilter className="size-4 text-muted" /> },
...options.map((o) => ({
id: o.key,
label: o.label,
icon: <ProviderIcon kind={o.key} fallbackLabel={o.label} className="size-4 rounded" />,
})),
];
return (
<DevicePicker
value={value ?? ALL_ACCOUNTS}
options={pickerOptions}
onChange={(id) => onChange(id === ALL_ACCOUNTS ? undefined : id)}
/>
);
}
8 changes: 7 additions & 1 deletion src/renderer/views/ProfileOverlay/parts/BreakdownBars.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export function BreakdownBars(props: {
loadingRows?: number;
emptyText?: string;
footer?: ReactNode;
/** Formats the raw count shown next to the percent (default `toLocaleString`). */
formatValue?: (count: number) => string;
}) {
const {
title,
Expand All @@ -33,6 +35,7 @@ export function BreakdownBars(props: {
loadingRows = 4,
emptyText,
footer,
formatValue = (n) => n.toLocaleString(),
} = props;
const rows = entries.slice(0, limit);

Expand All @@ -56,7 +59,10 @@ export function BreakdownBars(props: {
<div key={entry.key} className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-4 text-sm">
<span className="truncate font-medium text-foreground">{entry.label}</span>
<span className="shrink-0 tabular-nums text-muted">{entry.percent}%</span>
<span className="flex shrink-0 items-baseline gap-1.5 tabular-nums">
<span className="text-muted">{formatValue(entry.count)}</span>
<span className="text-muted/50">{entry.percent}%</span>
</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-foreground/10">
<div
Expand Down
17 changes: 12 additions & 5 deletions src/renderer/views/ProfileOverlay/parts/ModelUsage.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
import type { ProfileBreakdownEntry, ProfileTokenStats } from "@/shared/contracts";
import { formatCompact } from "../format";
import type { ActivityMetric } from "./ActivitySection";
import { BreakdownBars } from "./BreakdownBars";

export function ModelUsage(props: {
tokens: ProfileTokenStats | null;
coreModels: ProfileBreakdownEntry[];
tokensLoading: boolean;
metric: ActivityMetric;
}) {
const { tokens, coreModels, tokensLoading } = props;
const { tokens, coreModels, tokensLoading, metric } = props;

// Wait for token stats before choosing the source so the section doesn't flip
// from prompt-weighted to token-weighted (a reflow) mid-render.
const pending = tokensLoading && !tokens;
const byTokens = tokens?.available && tokens.models.length > 0;
// Follow the Prompts/Tokens toggle: token-weighted when "tokens" is active and
// token data exists, otherwise the prompt-weighted core mix.
const byTokens = metric === "tokens" && Boolean(tokens?.available) && tokens!.models.length > 0;
const models = byTokens ? tokens!.models : coreModels;
// Hold a skeleton until the token rollup resolves (matching the Providers
// column) so the default token view doesn't briefly paint the prompt mix then
// reflow once tokens arrive.
const pending = tokensLoading && !tokens;

if (!pending && models.length === 0) return null;

Expand All @@ -30,6 +36,7 @@ export function ModelUsage(props: {
entries={models}
loading={pending}
loadingRows={Math.min(4, Math.max(1, coreModels.length || 4))}
{...(byTokens ? { formatValue: formatCompact } : {})}
{...(footer ? { footer } : {})}
/>
);
Expand Down
17 changes: 13 additions & 4 deletions src/renderer/views/ProfileOverlay/parts/ProfileHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ export function ProfileHeader(props: {
currentDeviceId: string | null;
selection: ProfileSelection;
onSelect: (selection: ProfileSelection) => void;
/** Rendered on the same row as the device picker, before the actions. */
filter?: ReactNode;
/** Rendered on the same row as the device picker (Share / Edit). */
actions?: ReactNode;
}) {
const { identity, devices, currentDeviceId, selection, onSelect, actions } = props;
const { identity, devices, currentDeviceId, selection, onSelect, filter, actions } = props;
const plan = identity.plan ?? "Local";

const value =
Expand Down Expand Up @@ -80,10 +82,17 @@ export function ProfileHeader(props: {
<DevicePicker
value={value}
options={options}
onChange={(id) =>
onSelect(id === ALL_DEVICES ? { scope: "all" } : { scope: "device", deviceId: id })
}
onChange={(id) => {
// Preserve the active account filter across a device/scope switch.
const provider = selection.provider ? { provider: selection.provider } : {};
onSelect(
id === ALL_DEVICES
? { scope: "all", ...provider }
: { scope: "device", deviceId: id, ...provider },
);
}}
/>
{filter}
{actions}
</div>
</div>
Expand Down
19 changes: 16 additions & 3 deletions src/renderer/views/ProfileOverlay/useProfileData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface ProfileSelection {
scope: ProfileStatScope;
/** Selected device id when scope === "device"; undefined = current device. */
deviceId?: string;
/** Account-scoped provider filter; undefined = all accounts. */
provider?: string;
}

export interface ProfileData {
Expand Down Expand Up @@ -62,14 +64,25 @@ export function useProfileData(): ProfileData {
};
}, []);

const { scope, deviceId } = selection;
const { scope, deviceId, provider } = selection;
useEffect(() => {
let active = true;
const utcOffsetMinutes = -new Date().getTimezoneOffset();
const req = { utcOffsetMinutes, scope, ...(deviceId ? { deviceId } : {}) };
const req = {
utcOffsetMinutes,
scope,
...(deviceId ? { deviceId } : {}),
...(provider ? { provider } : {}),
};
setCoreLoading(true);
setTokensLoading(true);
setError(null);
// Drop the previous selection's token rollup so the token-weighted sections
// (StatStrip, Providers, Model usage) fall back to their skeletons instead of
// briefly showing another account's numbers under the newly selected filter.
// `core` is kept (it reloads from the fast SQLite tier) so the page chrome -
// including the account filter itself - stays mounted during the refetch.
setTokens(null);

void readBridge()
.getProfileCoreStats(req)
Expand Down Expand Up @@ -99,7 +112,7 @@ export function useProfileData(): ProfileData {
return () => {
active = false;
};
}, [scope, deviceId]);
}, [scope, deviceId, provider]);

async function saveIdentity(identity: ProfileIdentity): Promise<void> {
const response = await readBridge().setProfileIdentity(identity);
Expand Down
Loading