From fae1a375dabd04a1fc14f4fc413ce12fdd7a577b Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Tue, 16 Jun 2026 10:55:13 -0700 Subject: [PATCH] feat(profile): add per-account stats filter and split skill usage - Add AccountFilter picker with provider-scoped token and core rollups - Expose availableAccounts in contracts; preserve filter across device scope - Split skills and subagents in core stats, settings, and usage contracts - Show raw counts on breakdown bars; align model usage with metric toggle - Improve subagent detection and naming in usage recorder --- src/main/profile/coreStats.ts | 74 +++++++---- src/main/profile/tokenStats.ts | 4 +- src/renderer/state/usageRecorder.ts | 16 ++- .../ProfileOverlay/parts/AccountFilter.tsx | 34 +++++ .../ProfileOverlay/parts/BreakdownBars.tsx | 8 +- .../views/ProfileOverlay/parts/ModelUsage.tsx | 17 ++- .../ProfileOverlay/parts/ProfileHeader.tsx | 17 ++- .../views/ProfileOverlay/useProfileData.ts | 19 ++- .../SettingsOverlay/parts/ProfileSettings.tsx | 120 +++++++++++------- src/shared/contracts/profile.ts | 24 +++- 10 files changed, 241 insertions(+), 92 deletions(-) create mode 100644 src/renderer/views/ProfileOverlay/parts/AccountFilter.tsx diff --git a/src/main/profile/coreStats.ts b/src/main/profile/coreStats.ts index eaf6109a..615e45c1 100644 --- a/src/main/profile/coreStats.ts +++ b/src/main/profile/coreStats.ts @@ -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(); + const subagentCounts = new Map(); const mcpCounts = new Map(); 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, + 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(); + 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 ------------------------------------------------------ @@ -206,8 +219,10 @@ function emptyCoreStats( accounts: [], models: [], skills: [], + subagents: [], mcps: [], aiActions: [], + availableAccounts: [], }; } @@ -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; @@ -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(); @@ -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, @@ -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; diff --git a/src/main/profile/tokenStats.ts b/src/main/profile/tokenStats.ts index 9fd59271..1b84e353 100644 --- a/src/main/profile/tokenStats.ts +++ b/src/main/profile/tokenStats.ts @@ -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; @@ -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); diff --git a/src/renderer/state/usageRecorder.ts b/src/renderer/state/usageRecorder.ts index 6f581344..1ee74e7d 100644 --- a/src/renderer/state/usageRecorder.ts +++ b/src/renderer/state/usageRecorder.ts @@ -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; diff --git a/src/renderer/views/ProfileOverlay/parts/AccountFilter.tsx b/src/renderer/views/ProfileOverlay/parts/AccountFilter.tsx new file mode 100644 index 00000000..650b1d51 --- /dev/null +++ b/src/renderer/views/ProfileOverlay/parts/AccountFilter.tsx @@ -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: }, + ...options.map((o) => ({ + id: o.key, + label: o.label, + icon: , + })), + ]; + return ( + onChange(id === ALL_ACCOUNTS ? undefined : id)} + /> + ); +} diff --git a/src/renderer/views/ProfileOverlay/parts/BreakdownBars.tsx b/src/renderer/views/ProfileOverlay/parts/BreakdownBars.tsx index 12145c97..d2f7adeb 100644 --- a/src/renderer/views/ProfileOverlay/parts/BreakdownBars.tsx +++ b/src/renderer/views/ProfileOverlay/parts/BreakdownBars.tsx @@ -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, @@ -33,6 +35,7 @@ export function BreakdownBars(props: { loadingRows = 4, emptyText, footer, + formatValue = (n) => n.toLocaleString(), } = props; const rows = entries.slice(0, limit); @@ -56,7 +59,10 @@ export function BreakdownBars(props: {
{entry.label} - {entry.percent}% + + {formatValue(entry.count)} + {entry.percent}% +
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; @@ -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 } : {})} /> ); diff --git a/src/renderer/views/ProfileOverlay/parts/ProfileHeader.tsx b/src/renderer/views/ProfileOverlay/parts/ProfileHeader.tsx index 683e7aa9..f0b00e78 100644 --- a/src/renderer/views/ProfileOverlay/parts/ProfileHeader.tsx +++ b/src/renderer/views/ProfileOverlay/parts/ProfileHeader.tsx @@ -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 = @@ -80,10 +82,17 @@ export function ProfileHeader(props: { - 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}
diff --git a/src/renderer/views/ProfileOverlay/useProfileData.ts b/src/renderer/views/ProfileOverlay/useProfileData.ts index a8c53db2..289fe28a 100644 --- a/src/renderer/views/ProfileOverlay/useProfileData.ts +++ b/src/renderer/views/ProfileOverlay/useProfileData.ts @@ -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 { @@ -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) @@ -99,7 +112,7 @@ export function useProfileData(): ProfileData { return () => { active = false; }; - }, [scope, deviceId]); + }, [scope, deviceId, provider]); async function saveIdentity(identity: ProfileIdentity): Promise { const response = await readBridge().setProfileIdentity(identity); diff --git a/src/renderer/views/SettingsOverlay/parts/ProfileSettings.tsx b/src/renderer/views/SettingsOverlay/parts/ProfileSettings.tsx index 19608878..20c92c5a 100644 --- a/src/renderer/views/SettingsOverlay/parts/ProfileSettings.tsx +++ b/src/renderer/views/SettingsOverlay/parts/ProfileSettings.tsx @@ -1,5 +1,6 @@ -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { Pencil, Share2 } from "lucide-react"; +import type { ProfileBreakdownEntry, ProfileTokenProvider } from "@/shared/contracts"; import { Button, PixelLoader } from "@/renderer/components/common"; import { useProfileData } from "@/renderer/views/ProfileOverlay/useProfileData"; import { ProfileHeader } from "@/renderer/views/ProfileOverlay/parts/ProfileHeader"; @@ -12,33 +13,38 @@ import { ActivityInsights } from "@/renderer/views/ProfileOverlay/parts/Activity import { PluginUsage } from "@/renderer/views/ProfileOverlay/parts/PluginUsage"; import { ModelUsage } from "@/renderer/views/ProfileOverlay/parts/ModelUsage"; import { BreakdownBars } from "@/renderer/views/ProfileOverlay/parts/BreakdownBars"; +import { AccountFilter } from "@/renderer/views/ProfileOverlay/parts/AccountFilter"; import { AiActions } from "@/renderer/views/ProfileOverlay/parts/AiActions"; import { EditProfileDialog } from "@/renderer/views/ProfileOverlay/parts/EditProfileDialog"; import { ShareDialog } from "@/renderer/views/ProfileOverlay/parts/ShareDialog"; +import { formatCompact } from "@/renderer/views/ProfileOverlay/format"; + +/** Token-weighted bars show compact counts (e.g. "2.8M"); spread when by-tokens. */ +const tokenFormat = { formatValue: formatCompact }; + +/** Reshape a token-weighted provider/account into the generic breakdown entry. */ +function toEntry(p: ProfileTokenProvider): ProfileBreakdownEntry { + return { key: p.provider, label: p.label, count: p.tokens, percent: p.percent }; +} /** Profile + usage statistics, rendered as a Settings section. */ export function ProfileSettings() { const data = useProfileData(); const [editOpen, setEditOpen] = useState(false); const [shareOpen, setShareOpen] = useState(false); - const [metric, setMetric] = useState("prompts"); + const [pickedMetric, setPickedMetric] = useState(null); const { core, coreLoading, tokens, tokensLoading } = data; - // Default to Tokens once token stats resolve, until the user explicitly picks - // a metric. If the selected scope has no token data, keep the active tab valid. - const userPickedMetric = useRef(false); - useEffect(() => { - if (!tokens) return; - if (!tokens.available) { - if (metric === "tokens") setMetric("prompts"); - return; - } - if (!userPickedMetric.current) setMetric("tokens"); - }, [tokens, metric]); - const handleMetricChange = (next: ActivityMetric) => { - userPickedMetric.current = true; - setMetric(next); - }; + // Resolve the active metric at render time (not via a post-paint effect) so the + // breakdown sections never paint the prompt mix for a frame before flipping to + // tokens. Default to Tokens once token data exists; a user pick wins, but a + // "tokens" pick downgrades to prompts when the selected scope has no tokens. + const tokensAvailable = Boolean(tokens?.available); + const metric: ActivityMetric = + pickedMetric === "tokens" && !tokensAvailable + ? "prompts" + : (pickedMetric ?? (tokensAvailable ? "tokens" : "prompts")); + const handleMetricChange = (next: ActivityMetric) => setPickedMetric(next); if (coreLoading && !core) { return ( @@ -55,30 +61,38 @@ export function ProfileSettings() { ); } - // Prefer token-weighted per-provider usage (covers every provider incl. all - // ACP agents) once token stats resolve; fall back to prompt-weighted activity. - const providersByTokens = Boolean(tokens?.available && tokens.providers.length > 0); - const providerEntries = providersByTokens - ? tokens!.providers.map((p) => ({ - key: p.provider, - label: p.label, - count: p.tokens, - percent: p.percent, - })) - : core.providers; + // Follow the Prompts/Tokens toggle for the breakdown sections: token-weighted + // when "tokens" is active and token data exists, else prompt-weighted activity + // (which also covers every provider incl. all ACP agents). + const metricIsTokens = metric === "tokens" && Boolean(tokens?.available); + + const providersByTokens = metricIsTokens && tokens!.providers.length > 0; + const providerEntries = providersByTokens ? tokens!.providers.map(toEntry) : core.providers; // Per-account (per-profile) usage - only worth showing when the user actually // has multiple accounts/profiles (an account key carries an instance suffix). - const accountsByTokens = Boolean(tokens?.available && tokens.accounts.length > 0); - const accountEntries = accountsByTokens - ? tokens!.accounts.map((a) => ({ - key: a.provider, - label: a.label, - count: a.tokens, - percent: a.percent, - })) - : core.accounts; - const hasMultipleAccounts = accountEntries.some((a) => a.key.includes(":")); + const accountsByTokens = metricIsTokens && tokens!.accounts.length > 0; + const accountEntries = accountsByTokens ? tokens!.accounts.map(toEntry) : core.accounts; + // With a single account selected the breakdown collapses to one 100% bar for + // the account already named in the filter, so only show it when unfiltered. + const hasMultipleAccounts = + !data.selection.provider && accountEntries.some((a) => a.key.includes(":")); + + // Per-account filter (whole page) - only when more than one account exists. + const accountFilter = + core.availableAccounts.length > 1 ? ( + + data.setSelection({ + scope: data.selection.scope, + ...(data.selection.deviceId ? { deviceId: data.selection.deviceId } : {}), + ...(provider ? { provider } : {}), + }) + } + /> + ) : null; const headerActions = ( <> @@ -102,6 +116,7 @@ export function ProfileSettings() { currentDeviceId={data.currentDeviceId} selection={data.selection} onSelect={data.setSelection} + filter={accountFilter} actions={headerActions} /> @@ -114,7 +129,15 @@ export function ProfileSettings() { />
- + +
+
+ +
+ -
{hasMultipleAccounts ? ( ) : null}
- - -
-
+
diff --git a/src/shared/contracts/profile.ts b/src/shared/contracts/profile.ts index 12530bbb..bfe1b3cd 100644 --- a/src/shared/contracts/profile.ts +++ b/src/shared/contracts/profile.ts @@ -134,6 +134,14 @@ export interface ProfileSkillUsage { runCount: number; } +/** A selectable account for the per-account stats filter (account-scoped key). */ +export interface ProfileAccountRef { + /** Account-scoped agent kind, e.g. "claude" or "claude:work". */ + key: string; + /** Display label, e.g. "Claude" or "Claude - work". */ + label: string; +} + export const aiActionTypeSchema = z.enum(["commit", "pr", "conflict"]); export type AiActionType = z.infer; @@ -186,12 +194,20 @@ export interface ProfileCoreStats { models: ProfileBreakdownEntry[]; /** Threads started by presentation mode (chat vs CLI). */ modes: ProfileBreakdownEntry[]; - /** Top skills/subagents by run count. */ + /** Top skills by run count (`$skill`). */ skills: ProfileSkillUsage[]; + /** Top subagents by run count (`@agent`). */ + subagents: ProfileSkillUsage[]; /** Top MCP servers by tool-call count. */ mcps: ProfileSkillUsage[]; /** AI-performed git actions (commits / PRs / conflict resolutions). */ aiActions: ProfileAiAction[]; + /** + * Distinct accounts seen in the (unfiltered) usage log, for the per-account + * filter. Always the full set regardless of the active `provider` filter, so + * the picker stays stable while a single account is selected. + */ + availableAccounts: ProfileAccountRef[]; } // -- Token stats (durable local usage log) ---------------------------- @@ -239,6 +255,12 @@ export const profileStatsRequestSchema = z.object({ * an empty-but-valid blob today. */ deviceId: z.string().optional(), + /** + * Account-scoped provider filter. When set, every stat (heatmap, totals, + * breakdowns, plugins, ...) is scoped to events whose recorded account kind + * matches exactly, e.g. "claude" or "claude:work". Omit for all accounts. + */ + provider: z.string().optional(), }); export type ProfileStatsRequest = z.infer;